Type something to search...
Handling File Uploads in Next.js: Local Storage, S3, and Beyond

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.name as a path. A name like ../../.env is 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, but public is 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:

ServiceHow it worksNotes
Vercel Blobput() on the server, or client uploads with a token routeTight integration on Vercel, simple API
UploadThingDefine file routes with size and type rules; ready-made React componentsVery little code for typical uploads
Supabase StorageBuckets with row level security policiesNatural fit if you already use Supabase
Cloudflare R2S3-compatible API, no egress feesUse the S3 code above with a custom endpoint
Cloudinary / MuxUpload 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.type alone.
  • Serve user files with safe headers: correct Content-Type, X-Content-Type-Options: nosniff, and Content-Disposition: attachment for 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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading