File storage
Store avatars, organization logos and your own files in any S3-compatible bucket, serve them through durable URLs, and crop images before upload.
Launch Now stores uploaded files in an S3-compatible bucket with the AWS SDK. Out of the box it handles user avatars and organization logos: the browser crops the image, an API route validates and uploads it, and a second route serves it through a stable URL backed by short-lived signed URLs. The bucket stays private.
Any provider that speaks the S3 API works: AWS S3, Cloudflare R2, Tigris, MinIO and others.
Where the code lives
| File | Role |
|---|---|
lib/storage.ts | S3 client, upload/sign/delete helpers, image validation and public file keys. |
app/api/upload/avatar/route.ts | POST endpoint that validates and stores an avatar or logo. |
app/api/files/[...key]/route.ts | GET endpoint that redirects a durable URL to a fresh signed URL. |
components/avatar-uploader.tsx | AvatarUploader: file picker, crop dialog and upload. |
components/ui/cropper.tsx | Styled wrapper around @origin-space/image-cropper. |
lib/onboarding-actions.ts, lib/admin-actions.ts | Server actions that upload images sent as base64. |
Setup
Create credentials
In your provider's console, create an access key with read and write access to object storage.
Set the environment variables
AWS_ENDPOINT_URL_S3=https://s3.example.com
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=auto| Variable | Description |
|---|---|
AWS_ENDPOINT_URL_S3 | The S3 API endpoint of your provider. Must be a full URL. |
AWS_ACCESS_KEY_ID | Access key id. |
AWS_SECRET_ACCESS_KEY | Secret access key. |
AWS_REGION | Region of the bucket. Providers without regions accept auto. |
All four are required by lib/env.ts.
Check the bucket
Files go to a bucket named assets. On the first upload of each server process, lib/storage.ts tries to
create it and ignores the error if it already exists or if the key isn't allowed to create buckets. If your
access key can't create buckets, create a bucket named assets yourself.
Provider examples
| Provider | AWS_ENDPOINT_URL_S3 | AWS_REGION |
|---|---|---|
| AWS S3 | https://s3.<region>.amazonaws.com | Your bucket region, e.g. eu-west-1 |
| Cloudflare R2 | https://<account-id>.r2.cloudflarestorage.com | auto |
| Tigris | https://fly.storage.tigris.dev | auto |
| MinIO (local) | http://localhost:9000 | us-east-1 |
The client uses path-style URLs (forcePathStyle: true), which all of these accept. With R2, create an API
token with Object Read & Write permissions and use its access key id and secret.
Good to know: Bucket names are global on AWS S3, so assets is almost certainly taken there. Change
the BUCKET constant in lib/storage.ts to a unique name. See Customize.
How it works
The storage helpers
const s3 = new S3Client({
region: env.AWS_REGION,
endpoint: env.AWS_ENDPOINT_URL_S3,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
})
const BUCKET = "assets"| Export | Description |
|---|---|
uploadFile(key, body, contentType?) | Puts an object and returns its key. |
getSignedFileUrl(key, expiresIn = 3600) | Returns a signed GET URL valid for expiresIn seconds. |
deleteFile(key) | Deletes an object. |
validateImageUpload(input) | Checks an image by its magic bytes. |
uploadPublicImage(kind, input) | Validates, uploads under avatars/ or logos/, and returns { ok, key, url }. |
publicFileUrl(key) | Returns the durable URL /api/files/<key>. |
isPublicFileKey(key) | Whether a key may be served by /api/files. |
MAX_IMAGE_UPLOAD_BYTES | 2 * 1024 * 1024 (2 MB). |
Image validation
validateImageUpload() ignores the declared MIME type and file name. It reads the first bytes of the file
and only accepts PNG, JPEG and WebP images up to 2 MB. The stored file's extension and content type come from
the detected format. Keys are random: avatars/<uuid>.<ext> or logos/<uuid>.<ext>.
Upload endpoint
POST /api/upload/avatar takes a multipart/form-data body:
| Field | Description |
|---|---|
file | The image. |
kind | Optional. logo stores the file under logos/; anything else under avatars/. |
It requires a session and responds with:
| Status | Body |
|---|---|
200 | { "storageId": "avatars/<uuid>.jpg", "url": "/api/files/avatars/<uuid>.jpg" } |
400 | { "error": "..." }: no file, or not a valid PNG, JPEG or WebP image. |
401 | { "error": "Not authenticated" } |
413 | { "error": "The image must be 2 MB or smaller" } |
The endpoint only stores the file. Saving the URL (on the user, the organization…) is up to the caller.
Durable URLs
Signed URLs expire, so they can't be saved in the database. Instead, the app saves /api/files/<key> in
user.image or the organization logo. That route checks the key and redirects to a fresh signed URL:
const SIGNED_URL_TTL_SECONDS = 60 * 60
// Let browsers reuse the redirect for a while, but well within the URL's TTL.
const REDIRECT_CACHE_SECONDS = 60 * 30
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ key: string[] }> }
) {
const { key: segments } = await params
const key = segments.map((s) => decodeURIComponent(s)).join("/")
if (!isPublicFileKey(key)) {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
const url = await getSignedFileUrl(key, SIGNED_URL_TTL_SECONDS)
const response = NextResponse.redirect(url, 307)
response.headers.set(
"Cache-Control",
`public, max-age=${REDIRECT_CACHE_SECONDS}`
)
return response
}The route is public, so images work in emails and for signed-out visitors. Only keys matching
avatars/<name> or logos/<name> (letters, digits, ., _ and -, no sub-folders) are served; any other
key returns 404. Files you store under other prefixes stay private.
Avatar uploader
AvatarUploader is the round image picker used on the account page (features/account/user-profile-form.tsx)
and in the organization settings (features/organization/organization-general-settings.tsx).
<AvatarUploader
currentImageUrl={logoUrl}
type="organization"
onImageChange={(url) => {
setLogoUrl(url)
saveLogo.execute({ organizationId: organization.id, logo: url })
}}
onImageRemove={() => {
setLogoUrl(null)
saveLogo.execute({ organizationId: organization.id, logo: "" })
}}
/>| Prop | Type | Description |
|---|---|---|
currentImageUrl | string | null | Image to display. |
type | "user" | "organization" | With user, the component saves the URL on the signed-in user itself. |
onImageChange | (url: string) => void | Called with the durable URL after a successful upload. |
onImageRemove | () => void | Called when the user clicks the remove button. |
When the user picks a file:
- A dialog opens with the cropper (
components/ui/cropper.tsx) and a zoom slider from 1× to 3×. - On Apply, the crop area is drawn to a canvas and exported as a 256×256 JPEG.
- The JPEG is posted to
/api/upload/avatar. - With
type="user", the component calls theupdateProfileImageaction to save the URL on the user. Withtype="organization", it only callsonImageChange; the parent saves the logo.
Removing the image with type="user" saves an empty image on the user; for organizations the parent handles
it in onImageRemove.
Good to know: The uploader doesn't send kind, so organization logos are stored under avatars/
too. Both prefixes are served the same way. Append formData.append("kind", "logo") in
components/avatar-uploader.tsx if you want them separated.
Uploads from server actions
Some flows send the image as base64 to a server action instead of the upload route:
completeOnboardingAction in lib/onboarding-actions.ts and the admin uploadImage action in
lib/admin-actions.ts. Both reject strings longer than the base64 size of 2 MB before decoding, then call
uploadPublicImage("avatars", buffer):
const result = await uploadPublicImage(
"avatars",
Buffer.from(fileBase64, "base64")
)
if (!result.ok) throw new ActionError(result.error)
image = result.urlCustomize
Store other files
For files that aren't public images (exports, attachments), use uploadFile() and getSignedFileUrl()
directly, and store the key in your table. Serve them from a route or server action that checks the user
first, so only the owner gets a signed URL:
import { getSignedFileUrl, uploadFile } from "@/lib/storage"
const key = `exports/${ctx.user.id}/${randomUUID()}.csv`
await uploadFile(key, csv, "text/csv")
// Later, after checking ownership:
const url = await getSignedFileUrl(key, 300)Keys outside avatars/ and logos/ are never served by /api/files.
Add a public prefix
To serve another kind of public image through /api/files, add it in three places in lib/storage.ts:
PUBLIC_FILE_PREFIXES, the PublicFileKind type and the regular expression in isPublicFileKey().
Change the bucket or size limit
- The bucket name is the
BUCKETconstant inlib/storage.ts. - The size limit is
MAX_IMAGE_UPLOAD_BYTES. The upload route, the image validation and the base64 checks in the actions all read it.
Delete old files
Replacing an avatar or a logo doesn't delete the previous object. If storage cost matters, call
deleteFile(key) with the old key when you save a new image.