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
| Variable | Required | Purpose |
|---|---|---|
S3_BUCKET | no | Bucket name |
S3_ACCESS_KEY_ID | no | Access key |
S3_SECRET_ACCESS_KEY | no | Secret key |
S3_ENDPOINT | no | Base URL of a non-AWS gateway, e.g. MinIO or R2 |
S3_REGION | no | Defaults to us-east-1 |
S3_FORCE_PATH_STYLE | no | "true" to address buckets as a path segment |
S3_PUBLIC_URL | no | Public 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:upThe 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:
| Function | Returns |
|---|---|
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.
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.