Storage

S3-compatible object storage, with presigned URLs for browser uploads.

@kreogen/storage is a thin wrapper around the AWS SDK's S3 client. It is S3-compatible rather than S3-specific, so the same code path serves MinIO in docker-compose locally, Cloudflare R2 or AWS S3 in production, and anything else speaking the same protocol.

There is no vendor SDK to swap out when the project moves hosts — only S3_ENDPOINT changes.

The package imports server-only. Credentials must never reach the browser, so there is no client entry point and no @kreogen/storage/client. Browsers upload through a presigned URL instead — see below.

Configuration

VariableRequiredPurpose
S3_BUCKETnoBucket name
S3_ACCESS_KEY_IDnoAccess key
S3_SECRET_ACCESS_KEYnoSecret key
S3_ENDPOINTnoBase URL of a non-AWS gateway, e.g. MinIO or R2
S3_REGIONnoDefaults to us-east-1
S3_FORCE_PATH_STYLEno"true" to address buckets as a path segment
S3_PUBLIC_URLnoPublic base URL for reads, e.g. a CDN in front

Like every other integration here, storage degrades rather than fails. The storage export is undefined until the bucket, key and secret are all present, and the helpers below throw a named error rather than a stack trace from deep inside the SDK:

Storage is not configured. Set S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.

S3_FORCE_PATH_STYLE defaults to true whenever S3_ENDPOINT is set, which is the behaviour MinIO and most self-hosted gateways need — they serve buckets as endpoint/bucket/key rather than as a subdomain. It is wrong on real S3, which is why it is derived rather than hardcoded.

Locally

MinIO ships in docker-compose.yml and needs no account:

bun run docker:up

The console is at localhost:9001, the API at localhost:9000, and the root credentials are in docker-compose.yml. Create a bucket in the console, then point the app at it:

S3_ENDPOINT="http://localhost:9000"
S3_BUCKET="kreogen"
S3_ACCESS_KEY_ID="kreogen"
S3_SECRET_ACCESS_KEY="kreogen-secret"

Server-side uploads

put takes a key, a body and optional metadata. The key is the full path inside the bucket — the package does no namespacing of its own, which matters in a multi-tenant application:

import { put } from '@kreogen/storage';

const { key, url } = await put(
  `${orgId}/avatars/${user.id}.png`,
  buffer,
  { contentType: 'image/png', cacheControl: 'public, max-age=31536000' }
);

Prefixing with the organization id is worth doing even though nothing enforces it. Object keys are the only structure a bucket has, and a flat namespace makes "delete everything belonging to this tenant" impossible to express.

The rest of the surface:

FunctionReturns
put(key, body, options?){ key, url }
del(key)void
head(key)Object metadata, or throws if absent
list(prefix?)Array of objects under the prefix
getPublicUrl(key)A URL, derived from S3_PUBLIC_URL or the endpoint

getPublicUrl is string construction, not a signature. It tells you where an object would be served from; whether that URL actually resolves depends on the bucket's own policy. For anything private, use getDownloadUrl instead.

Browser uploads

Routing an upload through a Next.js route handler means the whole file is buffered by your server before it reaches the bucket, which turns a large upload into memory pressure and a request timeout on the one process serving every other user.

Presigned URLs avoid that entirely. The server authorises the upload; the bytes go straight from the browser to the bucket.

apps/app/app/api/uploads/route.ts
import { requireOrganization } from '@kreogen/auth/session';
import { getUploadUrl } from '@kreogen/storage';
import { parseRequestBody } from '@kreogen/validation';
import { z } from 'zod';

const schema = z.object({ filename: z.string().max(200) });

export const POST = async (request: Request) => {
  const { orgId } = await requireOrganization();
  const parsed = await parseRequestBody(request, schema);

  if (!parsed.ok) {
    return parsed.response;
  }

  const key = `${orgId}/uploads/${crypto.randomUUID()}-${parsed.data.filename}`;

  return Response.json({ key, url: await getUploadUrl(key) });
};

The route decides the key. Accepting one from the client instead lets a caller write to ../another-org/, and a presigned URL grants exactly the request it was signed for — including the path.

From the browser:

const { key, url } = await fetch('/api/uploads', {
  method: 'POST',
  body: JSON.stringify({ filename: file.name }),
}).then((response) => response.json());

await fetch(url, { method: 'PUT', body: file });

getDownloadUrl is the mirror image, for reading a private object:

const url = await getDownloadUrl(key, 60);

Both default to five minutes. Signed URLs are bearer credentials — anyone holding one can use it until it expires — so keep the window as short as the flow allows, and never put one in a page that gets cached.

Cleaning up

Nothing deletes objects for you. Deleting a row that references an object leaves the object behind, silently accruing cost, so pair the two:

await del(page.imageKey);
await db.page.delete({ where: { id: page.id } });

Order matters less than doing both. If the delete fails after the row is gone, you have an orphan; if the row survives a failed object delete, the next attempt can retry. A lifecycle rule on the bucket is the usual backstop for whatever slips through.