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. On the way up, the bytes never touch 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 browser asks a Server Action for a presigned
PUTURL. - The server checks auth, runs that path's Zod schema over the request, then signs the URL with
generatePresignedUploadUrl. - The browser uploads the file straight to R2 with one
PUTrequest to that URL. - Avatars only: the browser calls a second Server Action that checks the uploaded file's size, downloads it, resizes it with sharp, and saves the final variants.
The server issues every storage key, and the signature covers the content-type header, so the browser can't declare a different type without breaking it. It's the header that's pinned, not the bytes. No presigned URL carries a size condition, so nothing caps how much gets uploaded. Allowed file types and size covers what to tighten.
Server S3 client
lib/server/s3.ts is a server-only module. It creates one AWS SDK v3 S3Client from the STORAGE_S3_* env vars and exports eight helpers, all acting on STORAGE_S3_BUCKET: presign an upload, read an object's size, upload a buffer, download an object, delete an object, delete a whole prefix, create a folder placeholder, and list a prefix. Three of them behave in ways the names don't tell you:
generatePresignedUploadUrl(key, contentType, expiresIn = 300)pinscontent-typeas a signed header, so the browser must send the same type or R2 rejects the upload.deleteFolder(prefix)pages through every key under the prefix and deletes in batches of 1000, returning the count.listObjects(prefix?, delimiter?)returns one page only. It asks for 1000 keys and ignores the continuation token, so bigger folders list short.
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), then sanitizes the name and builds a timestamped key under the current folder. The filename goes through isPathSafe, which rejects slashes and ... The content type only has to be a non-empty string.
The browser then PUTs the file to uploadUrl with a matching Content-Type header. Deletes (deleteFile) confirm the key sits inside your app prefix, re-check admin auth, then remove the object, or the whole folder if the key ends in /.
Admins browse, upload, create folders, and delete at /dashboard/files. The UI lives in app/dashboard/(admin)/files/files-content.tsx; see Admin Dashboard for how that route is protected.
Avatar pipeline
Avatars are the one case where an uploaded file comes back down to the server. The browser shrinks the image first, then the server re-processes it with sharp so every avatar ends up the same size and format.
In the browser (app/dashboard/profile/profile-form.tsx, app/onboarding/profile-step.tsx): the AvatarUpload picker (components/ui/avatar-upload.tsx) runs validateAvatarFile on the chosen file first. On save, if the type is one canvas can handle, resizeForAvatar(file) validates it again and center-crops it to a 400×400 JPEG. Other allowed types upload as-is. Then getAvatarUploadUrl(contentType, fileExtension) returns a presigned URL under avatars/raw/, the browser PUTs the file, and it calls processAvatarAfterUpload(key).
On the server (app/actions/user.ts):
getAvatarUploadUrlrequires a session, rate-limits to 5 presign requests per minute per user, and validates the type againstavatarUploadSchema. The counter is a plain in-memoryMap, so each server instance counts on its own. Behind more than one instance, swap it for something shared.processAvatarAfterUploadruns the filename throughavatarFilenameSchemaand only accepts one beginning with the caller's own user ID. It reads the raw object's size with aHEADrequest (getObjectSize) before downloading anything, and deletes the object if the response carries no size or reports more than 2 MB. If theHEADrequest itself throws, the action fails without deleting anything. Then it downloads it and writes two WebP variants with sharp, 400×400 toavatars/full/and 80×80 toavatars/thumbnail/, refusing inputs over 50 megapixels (limitInputPixels). Finally it deletes the raw file and any previous avatar, and saves the new filename on the user via Better Auth.
To render an avatar, getAvatarUrl(filename, size?) and getAvatarSrcSet(filename) in lib/client/avatar.ts build the public URLs for the two sizes. Both build on getStorageUrl and buildStorageKey from lib/file-utils.ts. Hand them an external http(s) URL (a Google avatar, say) and getAvatarUrl returns it untouched, while getAvatarSrcSet returns null, so the image falls back to a plain src.
What you can change
Allowed file types and size
Two Zod schemas guard the presign step, and they're what you edit rather than the actions. avatarUploadSchema (lib/validations/user.ts) fixes the avatar fileExtension and contentType enums. fileUploadSchema (lib/validations/files.ts) holds the admin-upload filename rules.
Only avatars are really restricted, though. Admin uploads accept any content type. Nothing caps the size. Add a contentType enum to fileUploadSchema if your app needs one, and check the size in the browser before asking for a URL.
The avatar's 2 MB cap, its 400 and 80 pixel dimensions, its quality setting and the 50-megapixel sharp input ceiling (MAX_INPUT_PIXELS) all live in one AVATAR_CONFIG object in lib/client/avatar.ts, read by both the browser resizer and the server sharp step. QUALITY is a 0-1 number: the browser passes it straight to JPEG encoding, the server multiplies by 100 for WebP.
AVATAR_CONFIG.SUPPORTED_TYPES and avatarUploadSchema do two different jobs. SUPPORTED_TYPES only tells the browser whether it can canvas-resize the file (canResizeAvatar) and fills the file picker's accept attribute in avatar-upload.tsx. 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. The picker only offers types in SUPPORTED_TYPES, and adding one there also makes the browser try to canvas-resize it, so there is no list for types you accept but don't resize.
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 strips any trailing slashes from the CDN URL and leading slashes from the key, then joins them with a single /, so a trailing slash on the CDN URL is optional.
buildStorageKey prefixes every storage key with NEXT_PUBLIC_APP_NAME, so apps or environments that set different names stay isolated in the same bucket. Two deployments with the same name share a prefix.
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 env vars: for R2, set STORAGE_S3_REGION=auto and STORAGE_S3_ENDPOINT to your r2.cloudflarestorage.com URL; for AWS S3, use the real region (us-east-1, say) and that region's S3 endpoint. Point NEXT_PUBLIC_STORAGE_S3_CDN_URL at the new provider's public base URL too, or file and avatar links keep resolving against the old one. One code change is unavoidable: the connect-src directive in next.config.ts only allows browser uploads to https://*.r2.cloudflarestorage.com, so add the new provider's upload host there or every direct upload fails the CSP check. See Headers and CSP. Beyond that, code changes only come in if 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
}
]Environment variables
STORAGE_S3_KEY=your-access-key-id
STORAGE_S3_SECRET=your-secret-access-key
STORAGE_S3_REGION=auto
STORAGE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
STORAGE_S3_BUCKET=your-bucket-name
NEXT_PUBLIC_STORAGE_S3_CDN_URL=https://<bucket>.<account-id>.r2.devlib/validations/env.ts requires all six, and the endpoint and CDN values must parse as URLs. Leave any of them blank and that module throws the moment it loads, which takes the whole app down with it. 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 shell is built: sidebar navigation, header, breadcrumb, and mobile menu, plus adding nav items and gating them to admins.