Launch NowDocs

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

FileRole
lib/storage.tsS3 client, upload/sign/delete helpers, image validation and public file keys.
app/api/upload/avatar/route.tsPOST endpoint that validates and stores an avatar or logo.
app/api/files/[...key]/route.tsGET endpoint that redirects a durable URL to a fresh signed URL.
components/avatar-uploader.tsxAvatarUploader: file picker, crop dialog and upload.
components/ui/cropper.tsxStyled wrapper around @origin-space/image-cropper.
lib/onboarding-actions.ts, lib/admin-actions.tsServer 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

.env
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
VariableDescription
AWS_ENDPOINT_URL_S3The S3 API endpoint of your provider. Must be a full URL.
AWS_ACCESS_KEY_IDAccess key id.
AWS_SECRET_ACCESS_KEYSecret access key.
AWS_REGIONRegion 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

ProviderAWS_ENDPOINT_URL_S3AWS_REGION
AWS S3https://s3.<region>.amazonaws.comYour bucket region, e.g. eu-west-1
Cloudflare R2https://<account-id>.r2.cloudflarestorage.comauto
Tigrishttps://fly.storage.tigris.devauto
MinIO (local)http://localhost:9000us-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

lib/storage.ts
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"
ExportDescription
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_BYTES2 * 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:

FieldDescription
fileThe image.
kindOptional. logo stores the file under logos/; anything else under avatars/.

It requires a session and responds with:

StatusBody
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:

app/api/files/[...key]/route.ts
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).

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: "" })
  }}
/>
PropTypeDescription
currentImageUrlstring | nullImage to display.
type"user" | "organization"With user, the component saves the URL on the signed-in user itself.
onImageChange(url: string) => voidCalled with the durable URL after a successful upload.
onImageRemove() => voidCalled when the user clicks the remove button.

When the user picks a file:

  1. A dialog opens with the cropper (components/ui/cropper.tsx) and a zoom slider from 1× to 3×.
  2. On Apply, the crop area is drawn to a canvas and exported as a 256×256 JPEG.
  3. The JPEG is posted to /api/upload/avatar.
  4. With type="user", the component calls the updateProfileImage action to save the URL on the user. With type="organization", it only calls onImageChange; 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):

lib/onboarding-actions.ts
const result = await uploadPublicImage(
  "avatars",
  Buffer.from(fileBase64, "base64")
)
if (!result.ok) throw new ActionError(result.error)
image = result.url

Customize

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 BUCKET constant in lib/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.