File Uploads
Upload files directly from the browser to Cloudflare R2 using presigned URLs. Includes server utilities, a client image pipeline, and an admin file manager.
Overview
File storage uses Cloudflare R2, which exposes an S3-compatible API. Uploads go directly from the browser to R2 using presigned URLs. The file bytes never pass through your Next.js server.
The server-side utilities live in lib/server/s3.ts. This file is marked "use server" and uses the AWS SDK v3 configured to point at your R2 bucket.
How Direct Uploads Work
- The client requests a presigned upload URL from your server
- The server generates a short-lived signed URL using
generatePresignedUploadUrl - The client uploads the file directly to R2 using a
PUTrequest to that URL - After the upload completes, the client confirms the key back to your server
- The server optionally stores the object key in your database or triggers further processing (e.g. resizing an avatar)
This keeps large file payloads off your server and removes the need to proxy data through Next.js API routes.
Server Configuration
// lib/server/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "@/lib/validations/env";
const s3Client = new S3Client({
region: env.STORAGE_S3_REGION,
endpoint: env.STORAGE_S3_ENDPOINT, // Your R2 endpoint
credentials: {
accessKeyId: env.STORAGE_S3_KEY,
secretAccessKey: env.STORAGE_S3_SECRET,
},
});Generating a Presigned Upload URL
Call generatePresignedUploadUrl from a Server Action to produce a URL the client can use for a direct PUT:
"use server";
import { generatePresignedUploadUrl } from "@/lib/server/s3";
import { buildStorageKey } from "@/lib/file-utils";
export async function getUploadUrl(filename: string, contentType: string) {
const key = buildStorageKey("uploads", `${Date.now()}-${filename}`);
const uploadUrl = await generatePresignedUploadUrl(key, contentType); // defaults to 300s TTL
return { uploadUrl, key };
}On the client, use the URL to upload directly:
async function uploadFile(file: File) {
const { uploadUrl, key } = await getUploadUrl(file.name, file.type);
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
// Optionally save `key` to your database via another server action
await saveFileRecord(key, file.name, file.size);
}Available S3 Utilities
All functions in lib/server/s3.ts are server-only and work with your configured R2 bucket.
| Function | Description |
|---|---|
generatePresignedUploadUrl(key, contentType, expiresIn?) | Generates a signed URL for a direct browser upload. expiresIn defaults to 300 seconds. |
uploadBuffer(buffer, key, contentType) | Uploads a buffer server-side (used for processed files like thumbnails) |
downloadObject(key) | Downloads an object and returns a Buffer |
deleteObject(key) | Deletes a single object by key |
deleteFolder(prefix) | Deletes all objects under a prefix in batches of 1000. Returns the count of deleted objects. |
createFolder(key) | Creates a folder placeholder object at the given key |
listObjects(prefix?, delimiter?) | Lists objects and common prefixes (folders) under a prefix. Returns { objects: FileItem[], folders: FileItem[] }. |
Deleting a File
"use server";
import { deleteObject } from "@/lib/server/s3";
export async function deleteFile(key: string) {
await deleteObject(key);
// Then remove the record from your database
}CDN Access
Files stored in R2 are served via a public CDN URL. Set NEXT_PUBLIC_STORAGE_S3_CDN_URL to your R2 public bucket URL or a connected custom domain.
Use the getStorageUrl helper in lib/file-utils.ts to build a full URL from a storage key:
import { getStorageUrl } from "@/lib/file-utils";
const fileUrl = getStorageUrl(key);Storage Key Utilities
lib/file-utils.ts exports helpers for building consistent, namespaced storage keys. All keys are automatically prefixed with the value of NEXT_PUBLIC_APP_NAME so objects from different environments or tenants stay isolated.
| Function | Description |
|---|---|
getStorageUrl(path) | Prepends NEXT_PUBLIC_STORAGE_S3_CDN_URL to a storage key to produce a public URL |
buildStorageKey(...segments) | Joins path segments under the app-name prefix. Example: buildStorageKey("avatars", "full", filename) produces next-starter/avatars/full/filename |
buildStoragePrefix(path?) | Builds a prefix string (trailing slash) for use with listObjects. Example: buildStoragePrefix("avatars/full") produces next-starter/avatars/full/ |
getFileExtension(filename) | Returns the lowercased file extension including the dot, or "" if none |
sanitizeSlug(input, removeExtension?) | Lowercases and slugifies a string, optionally stripping the extension first |
formatBytes(bytes) | Formats a byte count as a human-readable string (e.g. 1.23 MB) |
formatRelativeTime(date) | Formats a date as a relative time string (e.g. 3 hours ago) |
isExternalUrl(url) | Returns true if the URL starts with http:// or https:// |
Client-Side Image Utilities
lib/client/image.ts provides browser-side helpers for validating and resizing images before upload.
| Function | Description |
|---|---|
validateImageFile(file, maxSize) | Returns { valid, error? }. Checks that the file is an image and within the size limit. |
canResize(file, supportedTypes) | Returns true if the file MIME type is in the supported list |
resizeImage(file, width, height, quality, fit?) | Resizes and re-encodes the image as JPEG using a canvas. fit defaults to "cover". Returns { success, blob? }. |
buildImageUrl(filename, storagePath) | Builds a full CDN URL for a stored image. Returns null for empty filenames; passes through external URLs unchanged. |
buildImageSrcSet(filename, sizes) | Builds a srcset string from an array of { path, width } size descriptors. |
Avatar Upload Pipeline
Avatar uploads use a two-step flow: the browser uploads the raw file to a temporary avatars/raw/ prefix, then a server action processes it into two WebP variants at different sizes.
Configuration (lib/client/avatar.ts)
export const AVATAR_CONFIG = {
FULL_SIZE: 400, // px — full-size avatar (400×400)
THUMBNAIL_SIZE: 80, // px — thumbnail avatar (80×80)
QUALITY: 1.0, // WebP quality (0–1 scale, 1.0 = maximum quality)
MAX_FILE_SIZE: 2 * 1024 * 1024, // 2 MB
SUPPORTED_TYPES: ["image/jpeg", "image/jpg", "image/png", "image/webp"],
} as const;Upload Flow
- Get a presigned URL. Call
getAvatarUploadUrl(contentType, fileExtension)inapp/actions/user.ts. This generates a presigned URL targeting theavatars/raw/prefix and returns{ uploadUrl, key }. - Upload the raw file. The client does a direct
PUTto the presigned URL. - Trigger processing. Call
processAvatarAfterUpload(filename). This server action:- Downloads the raw file from
avatars/raw/<filename> - Validates that the file is under 2 MB
- Uses
sharpto produce two WebP images:400×400(full) and80×80(thumbnail) - Uploads both to
avatars/full/andavatars/thumbnail/respectively - Deletes the raw file and any previous avatar images
- Updates the user record with the new filename
- Downloads the raw file from
Helper Functions
| Function | Description |
|---|---|
getAvatarUrl(filename, size?) | Returns the CDN URL for "full" (default) or "thumbnail" avatar |
getAvatarSrcSet(filename) | Returns a srcset string covering both sizes for responsive images |
resizeForAvatar(file) | Client-side: validates and resizes the file to 400×400 JPEG using canvas |
validateAvatarFile(file) | Client-side: validates file type and 2 MB size limit |
canResizeAvatar(file) | Returns true if the file type supports canvas-based resizing |
CORS Configuration
For direct browser uploads to work, the R2 bucket needs a CORS policy that allows PUT requests from your origin.
In your Cloudflare dashboard, navigate to R2 > your bucket > Settings > CORS Policy and add:
[
{
"AllowedOrigins": ["https://yourdomain.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3000
}
]For local development, also allow http://localhost:3000:
[
{
"AllowedOrigins": [
"https://yourdomain.com",
"http://localhost:3000"
],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3000
}
]Without this policy, the browser will block the upload with a CORS error.
File Management in the Admin Dashboard
Admins can browse, upload, create folders, and delete files from the dashboard at /dashboard/files. The file management UI lives in app/dashboard/(admin)/files/. It uses listObjects for folder-level navigation and file listings, and exposes upload, folder creation, and delete actions via server actions in app/actions/files.ts.
Access to this page is restricted to users with the admin role. See the Admin Dashboard page for how route protection works.
Required Environment Variables
STORAGE_S3_KEY=your-r2-access-key-id
STORAGE_S3_SECRET=your-r2-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.devSTORAGE_S3_REGION should be auto for R2. Find the endpoint on your R2 bucket page in the Cloudflare dashboard. NEXT_PUBLIC_STORAGE_S3_CDN_URL is the public URL for your bucket, which can also be a custom domain.
Admin Dashboard
Protected routes, role-based access control, sidebar navigation, and admin-only user and file management.
Dashboard Layout
How the dashboard sidebar, breadcrumbs, mobile header, and nested layouts work together in Next Starter, including auth gating, admin-only nav items, and responsive behavior.