Next Starter Logo

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

  1. The client requests a presigned upload URL from your server
  2. The server generates a short-lived signed URL using generatePresignedUploadUrl
  3. The client uploads the file directly to R2 using a PUT request to that URL
  4. After the upload completes, the client confirms the key back to your server
  5. 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.

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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

  1. Get a presigned URL. Call getAvatarUploadUrl(contentType, fileExtension) in app/actions/user.ts. This generates a presigned URL targeting the avatars/raw/ prefix and returns { uploadUrl, key }.
  2. Upload the raw file. The client does a direct PUT to the presigned URL.
  3. 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 sharp to produce two WebP images: 400×400 (full) and 80×80 (thumbnail)
    • Uploads both to avatars/full/ and avatars/thumbnail/ respectively
    • Deletes the raw file and any previous avatar images
    • Updates the user record with the new filename

Helper Functions

FunctionDescription
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.dev

STORAGE_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.

On this page