
Handling File Uploads in Next.js: Local Storage, S3, and Beyond
File uploads look simple: a file input, a submit button, and somewhere to put the bytes. In practice, the "somewhere" decides almost everything. Writing to local disk works on a single server but breaks on serverless platforms. Sending files through your own server works for avatars but falls over for 2 GB videos. Letting the browser upload straight to object storage scales well but needs signed URLs and a second step to tell your app the upload finished.
This guide works through those options in order of complexity. I'll start with the basics of receiving a file in a Server Action, then cover storing files on local disk and serving them back, uploading to Amazon S3 through your server, uploading directly from the browser with presigned URLs, showing progress, and finally managed services that handle all of it for you. Along the way I'll point out the validation and security checks every upload path needs.
Receiving a File in a Server Action
The simplest upload in the App Router is a form with a file input and a Server Action. The file arrives as a standard File object in FormData.
// src/app/avatar/page.tsx
import { uploadAvatar } from "./actions";
export default function AvatarPage() {
return (
<form action={uploadAvatar}>
<input
type="file"
name="avatar"
accept="image/png,image/jpeg,image/webp"
required
/>
<button type="submit">Upload</button>
</form>
);
}
When a Server Action is passed to action, React sets the form's encoding to multipart/form-data for you, so you don't need encType.
// src/app/avatar/actions.ts
"use server";
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
export async function uploadAvatar(formData: FormData) {
const file = formData.get("avatar");
if (!(file instanceof File) || file.size === 0) {
throw new Error("No file uploaded");
}
if (file.size > MAX_BYTES) {
throw new Error("File is too large");
}
if (!ALLOWED_TYPES.has(file.type)) {
throw new Error("Unsupported file type");
}
const bytes = Buffer.from(await file.arrayBuffer());
// Store `bytes` somewhere (see the next sections).
}
Three things to understand before going further.
The accept attribute is a hint, not a check. It filters the file picker, but anyone can send any file to your action. Always validate on the server.
file.type comes from the client. The browser guesses it from the file extension, and an attacker can set it to anything. It's fine as a first filter, but for images you should verify the actual contents, for example by checking magic bytes or by decoding the file with an image library like sharp, which fails on files that aren't real images.
Server Actions have a 1 MB body limit by default. That limit protects your server from huge request bodies. Raise it in next.config.ts if you need to:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "5mb",
},
},
};
export default nextConfig;
The limit covers the whole multipart request, including boundaries and other form fields, so leave some headroom above your largest expected file. If your app uses proxy.ts, there's also a separate limit on how much request body Proxy buffers (10 MB by default, configurable with experimental.proxyClientMaxBodySize).
For anything beyond a few megabytes, don't keep raising this number. Send large files directly to storage instead, as shown later.
Option 1: Storing Files on Local Disk
If you run Next.js as a long-lived Node.js server, for example in Docker with output: "standalone" or on a VPS, writing to disk is the simplest storage there is.
// src/lib/storage/local.ts
import "server-only";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const UPLOAD_DIR =
process.env.UPLOAD_DIR ?? path.join(process.cwd(), "uploads");
const EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export async function saveLocalFile(file: File) {
const ext = EXTENSIONS[file.type];
if (!ext) throw new Error("Unsupported file type");
const key = `${randomUUID()}.${ext}`;
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(
path.join(UPLOAD_DIR, key),
Buffer.from(await file.arrayBuffer()),
);
return key;
}
The important choices:
- Generate the filename yourself. Never use
file.nameas a path. A name like../../.envis a path traversal attack, and even innocent names collide. A random UUID plus an extension derived from the validated type avoids both. - Don't write into
public. It's tempting because files there are served automatically, butpublicis part of your build output: it gets replaced on every deploy, and files added after the build aren't reliably served. Use a separate directory, mounted as a persistent volume in Docker. - Store the key in your database, alongside who uploaded it and when. The file system is just a blob store.
Serving Local Files Back
Since the files aren't in public, serve them through a Route Handler. This also gives you a place to check permissions.
// src/app/files/[key]/route.ts
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
const UPLOAD_DIR =
process.env.UPLOAD_DIR ?? path.join(process.cwd(), "uploads");
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
webp: "image/webp",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ key: string }> },
) {
const { key } = await params;
// Only allow the exact format we generate: uuid.ext
if (!/^[0-9a-f-]{36}\.(png|jpg|webp)$/.test(key)) {
return new Response("Not found", { status: 404 });
}
const filePath = path.join(UPLOAD_DIR, key);
try {
const info = await stat(filePath);
const ext = key.split(".").pop()!;
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
return new Response(stream, {
headers: {
"Content-Type": CONTENT_TYPES[ext],
"Content-Length": String(info.size),
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
} catch {
return new Response("Not found", { status: 404 });
}
}
The regex check is the security boundary: it rejects anything that isn't a filename you generated, so ../ tricks never reach the file system. Streaming with createReadStream avoids loading large files into memory. Because each key is unique and never reused, the response can be cached forever. If files are private, check the user's session at the top of the handler before streaming.
When Local Disk Stops Working
Local storage breaks down when:
- You deploy to a serverless platform. Function file systems are read-only or temporary, and each instance has its own.
- You run more than one server. A file uploaded to instance A doesn't exist on instance B.
- You need backups, CDN delivery, or files larger than your disk comfortably holds.
That's when object storage takes over.
Option 2: Uploading to S3 Through Your Server
Amazon S3, and S3-compatible services like Cloudflare R2, MinIO, and DigitalOcean Spaces, store files as objects in a bucket. The AWS SDK v3 is modular, so you install only the S3 client:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// src/lib/storage/s3.ts
import "server-only";
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
region: process.env.S3_REGION!,
// For R2 or MinIO, also set `endpoint` (and `forcePathStyle: true` for MinIO).
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
});
export const BUCKET = process.env.S3_BUCKET!;
Uploading a small file from a Server Action is one command:
// src/app/avatar/actions.ts
"use server";
import { randomUUID } from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { revalidatePath } from "next/cache";
import { s3, BUCKET } from "@/lib/storage/s3";
import { getCurrentUser } from "@/lib/auth";
export async function uploadAvatar(formData: FormData) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
const file = formData.get("avatar");
if (
!(file instanceof File) ||
file.size === 0 ||
file.size > 2 * 1024 * 1024
) {
throw new Error("Invalid file");
}
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
throw new Error("Unsupported file type");
}
const key = `avatars/${user.id}/${randomUUID()}`;
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(await file.arrayBuffer()),
ContentType: file.type,
}),
);
// Save `key` on the user record in your database here.
revalidatePath("/avatar");
}
This is the right approach for small files where you want to inspect or transform the content on the server (resizing an avatar, stripping image metadata) before it's stored. The downside is that every byte passes through your server, counts against the body size limit, and keeps a function running for the length of the upload.
Option 3: Direct Browser Uploads with Presigned URLs
For larger files, let the browser upload straight to S3. Your server's job shrinks to two small steps: issue a short-lived, signed permission to upload one specific object, and record the result afterward.
// src/app/uploads/actions.ts
"use server";
import { randomUUID } from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "@/lib/storage/s3";
import { getCurrentUser } from "@/lib/auth";
const MAX_BYTES = 200 * 1024 * 1024; // 200 MB
const ALLOWED = new Set([
"video/mp4",
"application/pdf",
"image/png",
"image/jpeg",
]);
export async function createUploadUrl(input: { type: string; size: number }) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
if (!ALLOWED.has(input.type)) throw new Error("Unsupported file type");
if (input.size <= 0 || input.size > MAX_BYTES)
throw new Error("File too large");
const key = `uploads/${user.id}/${randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: input.type,
ContentLength: input.size,
}),
{ expiresIn: 60 },
);
return { url, key };
}
Because ContentType and ContentLength are part of the signature, the browser must send exactly those values or S3 rejects the upload. The URL expires after 60 seconds, so a leaked URL is useful for very little. The size check here runs on the size the client claims; signing ContentLength is what forces the real upload to match it.
The client component requests a URL, then uploads with XMLHttpRequest, which (unlike fetch) reports upload progress:
// src/app/uploads/uploader.tsx
"use client";
import { useState } from "react";
import { createUploadUrl, confirmUpload } from "./actions";
function putWithProgress(
url: string,
file: File,
onProgress: (percent: number) => void,
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`Upload failed with status ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error"));
xhr.send(file);
});
}
export function Uploader() {
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
try {
const { url, key } = await createUploadUrl({
type: file.type,
size: file.size,
});
setProgress(0);
await putWithProgress(url, file, setProgress);
await confirmUpload({ key, name: file.name });
setProgress(100);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
setProgress(null);
}
}
return (
<div>
<input type="file" onChange={handleChange} />
{progress !== null && <progress value={progress} max={100} />}
{error && <p role="alert">{error}</p>}
</div>
);
}
The browser can't PUT to your bucket until you allow it with a CORS rule. In the S3 console, add a CORS configuration like this to the bucket:
[
{
"AllowedOrigins": ["https://your-app.com", "http://localhost:3000"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"MaxAgeSeconds": 3000
}
]
Confirming the Upload
The presigned URL only proves the browser was allowed to upload. Before you trust the key, confirm the object really exists and belongs to this user:
// src/app/uploads/actions.ts (continued)
import { HeadObjectCommand } from "@aws-sdk/client-s3";
import { revalidatePath } from "next/cache";
export async function confirmUpload(input: { key: string; name: string }) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
if (!input.key.startsWith(`uploads/${user.id}/`)) {
throw new Error("Forbidden");
}
const head = await s3.send(
new HeadObjectCommand({ Bucket: BUCKET, Key: input.key }),
);
// Save to your database: key, original name (for display only),
// head.ContentLength, head.ContentType, user.id, createdAt.
revalidatePath("/uploads");
}
The prefix check stops a user from claiming another user's object. HeadObject returns the real size and type as stored in S3, which is what you should save. The original filename is fine to keep for display, but never use it as a storage path.
For extra safety, you can also configure S3 Event Notifications so the bucket notifies your app (through SQS, SNS, or a Lambda) whenever an object lands, rather than relying on the browser to call confirmUpload. Uploads that are never confirmed can be cleaned up with a lifecycle rule on the uploads/ prefix.
Serving Private Files
Keep the bucket private and hand out short-lived download URLs when someone is allowed to see a file:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "@/lib/storage/s3";
export async function getDownloadUrl(key: string, fileName: string) {
return getSignedUrl(
s3,
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(fileName)}"`,
}),
{ expiresIn: 300 },
);
}
Call it from a Server Component or action after your permission check. ResponseContentDisposition makes the browser download the file under its original name instead of rendering it inline, which matters for user-uploaded HTML or SVG files that could otherwise run scripts in your users' browsers.
For public images, put a CDN such as CloudFront in front of the bucket and add the CDN host to images.remotePatterns in next.config.ts, so next/image can optimize them. Optimizing Images in Next.js covers that configuration.
Option 4: Managed Upload Services
If you'd rather not manage buckets, CORS, and presigned URLs, several services wrap the whole flow:
| Service | How it works | Notes |
|---|---|---|
| Vercel Blob | put() on the server, or client uploads with a token route | Tight integration on Vercel, simple API |
| UploadThing | Define file routes with size and type rules; ready-made React components | Very little code for typical uploads |
| Supabase Storage | Buckets with row level security policies | Natural fit if you already use Supabase |
| Cloudflare R2 | S3-compatible API, no egress fees | Use the S3 code above with a custom endpoint |
| Cloudinary / Mux | Upload plus processing (image transforms, video transcoding) | Pay for the processing you'd otherwise build |
They all use the same pattern underneath: the server authorizes, the browser uploads directly, and a callback tells your app it's done. Choosing one is mostly about where you host and what processing you need.
Upload Security Checklist
Whatever storage you use, check these:
- Authenticate every upload action. Server Actions and Route Handlers are public endpoints.
- Validate size and type on the server, and enforce size at the storage layer where you can (signed
ContentLength, bucket policies). - Generate your own storage keys. Never build paths from user input.
- Verify contents for formats you process, such as images you resize. Don't rely on
file.typealone. - Serve user files with safe headers: correct
Content-Type,X-Content-Type-Options: nosniff, andContent-Disposition: attachmentfor anything that isn't a known-safe image. Ideally, serve them from a separate domain so a malicious file can't access your app's cookies. - Keep buckets private by default and use short-lived signed URLs.
- Rate-limit uploads per user to prevent storage abuse.
- Consider malware scanning for files that other users will download.
Conclusion
The right upload strategy depends on file size and where you deploy. For small files on a single server, a Server Action that writes to a private directory and a Route Handler that streams it back is all you need. On serverless or multiple instances, move to S3 or a compatible store, either through your server for small files you want to inspect, or directly from the browser with presigned URLs for anything large. Managed services wrap the same pattern if you'd rather not build it.
In every case, the rules don't change: validate on the server, generate your own keys, keep storage private, and record uploads in your database only after confirming they exist.


