Next Starter Logo

File Uploads

How Next Starter handles file uploads: direct browser-to-Cloudflare R2 transfers via presigned URLs, with server-side validation and an S3-compatible config.

How it works

Files go straight from the browser to storage. They never pass through your Next.js server. Storage is Cloudflare R2 by default, used through its S3-compatible API.

The browser can't upload to a private bucket on its own, so the server hands it a presigned URL: a temporary, signed link that grants permission to upload one specific file for a short time (300 seconds here). The link already encodes the bucket, the file's storage key, and its content type, so the browser only has to send the bytes.

The flow:

  1. The browser asks a Server Action for a presigned PUT URL.
  2. The server checks the request (auth, file type, key), then signs the URL with generatePresignedUploadUrl.
  3. The browser uploads the file straight to R2 with one PUT request to that URL.
  4. For avatars, the browser then calls a second Server Action that downloads the uploaded file, resizes it with sharp, and saves the final variants.

This keeps large files off your server, while the server still controls every key, content type, and size limit.

The S3 helpers live in lib/server/s3.ts (marked "use server"). The browser builds public URLs and resizes images via lib/client/image.ts and lib/client/avatar.ts. Storage-key helpers live in lib/file-utils.ts.

Server S3 client

lib/server/s3.ts creates one AWS SDK v3 S3Client from the STORAGE_S3_* env vars (region, endpoint, key, secret). Because it speaks plain S3, pointing at another provider is a config change, not a code change. See Swapping providers.

Storage functions

All are server-only and act on the STORAGE_S3_BUCKET bucket.

FunctionWhat it does
generatePresignedUploadUrl(key, contentType, expiresIn?)Signs a direct-upload PUT URL. expiresIn defaults to 300 seconds. It pins content-type as a signed header, so the browser must send the same type or R2 rejects the upload.
uploadBuffer(buffer, key, contentType)Uploads bytes from the server (used for the sharp-processed avatars).
downloadObject(key)Downloads an object as a Buffer.
deleteObject(key)Deletes one object.
deleteFolder(prefix)Deletes everything under a prefix in batches of 1000; returns the count.
createFolder(key)Writes an empty folder-placeholder object.
listObjects(prefix?, delimiter?)Returns { objects, folders } for browsing.

Generic file uploads

The admin file manager is the reference example. getFileUploadUrl in app/actions/files.ts:

  • Requires an admin session (requireAdmin).
  • Validates filename and contentType against fileUploadSchema (lib/validations/files.ts), which rejects unsafe paths with isPathSafe.
  • Sanitizes the name, builds a timestamped key under the current folder, and returns { uploadUrl, key }.

The browser then PUTs the file to uploadUrl with a matching Content-Type header. Deletes (deleteFile) re-check admin auth and confirm the key sits inside your app prefix before removing it.

Avatar pipeline

Avatars use a two-step flow: the browser shrinks the image first, then the server re-processes it with sharp for a clean, consistent result.

Browser (app/dashboard/profile/profile-form.tsx, app/onboarding/profile-step.tsx):

  1. If the file is a resizable type, resizeForAvatar(file) validates it and canvas-resizes it to a 400×400 JPEG. (Other allowed types upload as-is.)
  2. getAvatarUploadUrl(contentType, fileExtension) returns a presigned URL pointing at avatars/raw/.
  3. The browser PUTs the file, then calls processAvatarAfterUpload(key).

Server (app/actions/user.ts):

  • getAvatarUploadUrl requires a session, rate-limits to 5 uploads per minute per user (in-memory), and validates the type against avatarUploadSchema.
  • processAvatarAfterUpload downloads the uploaded file, enforces the 2 MB cap, then uses sharp to make two WebP variants: 400×400 (full) and 80×80 (thumbnail). It uploads them to avatars/full/ and avatars/thumbnail/, deletes the raw file plus any previous avatar, and saves the new filename on the user via Better Auth.

Avatar helpers (lib/client/avatar.ts)

FunctionWhat it does
getAvatarUrl(filename, size?)Public URL for "full" (default) or "thumbnail".
getAvatarSrcSet(filename)srcset string covering both sizes.
resizeForAvatar(file)Validates and canvas-resizes to a 400×400 JPEG.
validateAvatarFile(file)Checks the file is an image within the 2 MB cap.
canResizeAvatar(file)Checks whether the type is one the browser can canvas-resize (SUPPORTED_TYPES).

What you can change

Allowed file types and size

Two Zod schemas gate uploads. Edit these, not the actions:

SchemaFileControls
avatarUploadSchemalib/validations/user.tsAllowed avatar fileExtension and contentType enums (what the presign step accepts).
fileUploadSchemalib/validations/files.tsAdmin-upload filename and content-type rules.

The avatar's 2 MB cap, dimensions, and quality live in AVATAR_CONFIG (lib/client/avatar.ts). Both the browser resizer and the server sharp step read these values:

export const AVATAR_CONFIG = {
  FULL_SIZE: 400,        // px
  THUMBNAIL_SIZE: 80,    // px
  QUALITY: 1.0,          // 0–1, mapped to WebP 0–100
  MAX_FILE_SIZE: 2 * 1024 * 1024, // 2 MB
  SUPPORTED_TYPES: ["image/jpeg", "image/jpg", "image/png", "image/webp"],
} as const;

SUPPORTED_TYPES and avatarUploadSchema do two different jobs. SUPPORTED_TYPES only tells the browser whether it can canvas-resize the file (canResizeAvatar). avatarUploadSchema decides which types the presign step actually accepts, and it already allows .gif, which SUPPORTED_TYPES does not. To accept a new avatar type, add it to avatarUploadSchema. Add it to SUPPORTED_TYPES only if you also want the browser to pre-resize it.

Bucket and CDN

Point STORAGE_S3_BUCKET at your bucket and NEXT_PUBLIC_STORAGE_S3_CDN_URL at its public base URL (an R2 public bucket or a custom domain). Public URLs come from getStorageUrl(key) in lib/file-utils.ts, which just concatenates the CDN URL and the key with nothing in between, so the CDN URL must end with a trailing slash, or every generated link breaks.

Every storage key is prefixed with NEXT_PUBLIC_APP_NAME (via buildStorageKey), which keeps separate apps or environments isolated in the same bucket.

Swapping the storage provider

Because lib/server/s3.ts is standard AWS SDK v3, any S3-compatible service works (AWS S3, Backblaze B2, MinIO, DigitalOcean Spaces). You only change the STORAGE_S3_* env vars:

  • R2: set STORAGE_S3_REGION=auto and STORAGE_S3_ENDPOINT to your r2.cloudflarestorage.com URL.
  • AWS S3: set STORAGE_S3_REGION to the real region (e.g. us-east-1) and STORAGE_S3_ENDPOINT to that region's S3 endpoint.

Both env vars are required by validation (lib/validations/env.ts), so set a real endpoint rather than leaving it blank. No code edits are needed unless a provider needs special request options.

CORS

Direct browser uploads need a bucket CORS policy that allows PUT from your origin. In Cloudflare, go to R2 → bucket → Settings → CORS Policy:

[
  {
    "AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["Content-Type"],
    "MaxAgeSeconds": 3000
  }
]

Without it, the browser blocks the upload with a CORS error.

Admin file manager

Admins browse, upload, create folders, and delete files at /dashboard/files. The UI lives in app/dashboard/(admin)/files/ (files-content.tsx) and calls the actions in app/actions/files.ts, which use listObjects for navigation. Access is limited to the admin role. See Admin Dashboard for route protection.

Environment variables

VariableExampleNotes
STORAGE_S3_KEYyour-access-key-idAccess key ID.
STORAGE_S3_SECRETyour-secret-access-keySecret access key.
STORAGE_S3_REGIONautoauto for R2; the real region for AWS S3.
STORAGE_S3_ENDPOINThttps://<account>.r2.cloudflarestorage.comS3 API endpoint (required).
STORAGE_S3_BUCKETyour-bucket-nameTarget bucket.
NEXT_PUBLIC_STORAGE_S3_CDN_URLhttps://<bucket>.<account>.r2.dev/Public base URL; must end with /.
NEXT_PUBLIC_APP_NAMEnext-starterPrefixes every storage key.

See Environment Variables for the full list.

On this page