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:
- The browser asks a Server Action for a presigned
PUTURL. - The server checks the request (auth, file type, key), then signs the URL with
generatePresignedUploadUrl. - The browser uploads the file straight to R2 with one
PUTrequest to that URL. - 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.
| Function | What 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
filenameandcontentTypeagainstfileUploadSchema(lib/validations/files.ts), which rejects unsafe paths withisPathSafe. - 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):
- 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.) getAvatarUploadUrl(contentType, fileExtension)returns a presigned URL pointing atavatars/raw/.- The browser
PUTs the file, then callsprocessAvatarAfterUpload(key).
Server (app/actions/user.ts):
getAvatarUploadUrlrequires a session, rate-limits to 5 uploads per minute per user (in-memory), and validates the type againstavatarUploadSchema.processAvatarAfterUploaddownloads the uploaded file, enforces the 2 MB cap, then uses sharp to make two WebP variants:400×400(full) and80×80(thumbnail). It uploads them toavatars/full/andavatars/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)
| Function | What 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:
| Schema | File | Controls |
|---|---|---|
avatarUploadSchema | lib/validations/user.ts | Allowed avatar fileExtension and contentType enums (what the presign step accepts). |
fileUploadSchema | lib/validations/files.ts | Admin-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=autoandSTORAGE_S3_ENDPOINTto yourr2.cloudflarestorage.comURL. - AWS S3: set
STORAGE_S3_REGIONto the real region (e.g.us-east-1) andSTORAGE_S3_ENDPOINTto 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
| Variable | Example | Notes |
|---|---|---|
STORAGE_S3_KEY | your-access-key-id | Access key ID. |
STORAGE_S3_SECRET | your-secret-access-key | Secret access key. |
STORAGE_S3_REGION | auto | auto for R2; the real region for AWS S3. |
STORAGE_S3_ENDPOINT | https://<account>.r2.cloudflarestorage.com | S3 API endpoint (required). |
STORAGE_S3_BUCKET | your-bucket-name | Target bucket. |
NEXT_PUBLIC_STORAGE_S3_CDN_URL | https://<bucket>.<account>.r2.dev/ | Public base URL; must end with /. |
NEXT_PUBLIC_APP_NAME | next-starter | Prefixes every storage key. |
See Environment Variables for the full list.
Admin Dashboard
How Next Starter builds the admin dashboard with role-based access control: a role-gated route group plus admin-only user management and R2 file browsing.
Dashboard Layout
How the dashboard layout is built: sidebar navigation, header, breadcrumb, mobile menu, and past-due banner, plus adding nav items and gating them to admins.