Almost every full-stack project I've worked on eventually needs Next.js file uploads: profile photos, PDFs, invoices, videos, documents attached to a support ticket. And almost every time I inherit a codebase, the upload feature is the one quietly causing problems. Requests time out, the server's memory spikes, large files fail without explanation, or files get stored with no record of who owns them. In this post I'll walk through the pattern I now use by default: the browser uploads directly to S3 using a presigned URL, while a Node.js backend decides who's allowed to upload what and confirms the file actually landed.

This isn't the only way to do it, but after 8+ years of building apps across Android, React, Next.js, Node.js and Laravel, it's the approach that has caused me the fewest late-night debugging sessions.

Why Next.js File Uploads Shouldn't Go Through Your Server

The naive approach is simple: the browser sends a multipart/form-data request to your API, the API parses it with something like Multer, and then forwards the file to storage. It works on localhost. It gets painful in production for a few reasons:

  • Body size limits. Serverless platforms cap request bodies. On Vercel, function request bodies are limited to a few megabytes, and Next.js Server Actions have a default body size limit of 1MB. A single phone photo can blow past that.
  • You pay for every byte twice. The file travels client → your server → storage. Your server is now a slow, expensive proxy.
  • Memory and timeouts. Buffering uploads in memory on a Node.js process is a great way to discover your container's memory limit. Long uploads on slow mobile connections hit function timeouts.
  • Scaling. Upload traffic competes with your normal API traffic for the same workers.

Presigned URLs flip this around. Your backend never touches the file bytes. It only issues a short-lived, tightly scoped permission slip that says: this user may upload one file of this type, up to this size, to this exact key, in the next 60 seconds. The browser sends the file straight to S3 (or any S3-compatible store like Cloudflare R2 or DigitalOcean Spaces).

When proxying is fine

If you're on a long-running Node.js server, files are tiny (say, small avatars), and traffic is low, uploading through your API is acceptable. I still reach for presigned URLs because the pattern scales without a rewrite later.

The Presigned URL Flow, Step by Step

Here's the full lifecycle I implement. Thinking about it as three separate requests makes the REST API design much cleaner:

  1. Request permission. The Next.js client calls POST /uploads/presign with the file's content type and size. The backend authenticates the user, validates the type and size, generates a random storage key, creates a PENDING database row, and returns a presigned POST policy.
  2. Upload directly. The browser sends the file to S3 using the returned URL and form fields. S3 itself enforces the size and content-type conditions baked into the policy.
  3. Confirm. The client calls POST /uploads/:id/confirm. The backend checks S3 to verify the object exists, records its real size, and marks the row READY. Only now can the file be attached to anything else, like a user profile or a message.

The database row is the part people skip, and it's the part that saves you. Without it, you have files in a bucket with no owner, no status and no way to clean up abandoned uploads.

Why presigned POST instead of presigned PUT

Presigned PUT URLs are simpler, but they can't enforce a maximum file size on their own. A presigned POST policy supports a content-length-range condition, so S3 rejects oversized files before you store them. If someone tampers with the client and tries to upload a 2GB file, S3 says no. That's validation you don't have to trust the browser for.

Building the Node.js Backend Endpoints

Here's a trimmed version of the Express router I use. It assumes you have an auth middleware that sets req.user and some ORM (Prisma-style syntax shown here). It uses AWS SDK v3.

import express from "express";
import crypto from "node:crypto";
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { db } from "./db.js";
import { requireAuth } from "./auth.js";

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOAD_BUCKET;
const MAX_BYTES = 10 * 1024 * 1024; // 10MB

const ALLOWED_TYPES = new Map([
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
  ["application/pdf", "pdf"],
]);

const router = express.Router();

router.post("/uploads/presign", requireAuth, async (req, res) => {
  const { contentType, size } = req.body;

  const ext = ALLOWED_TYPES.get(contentType);
  if (!ext) {
    return res.status(415).json({ error: "Unsupported file type" });
  }
  if (!Number.isInteger(size) || size <= 0 || size > MAX_BYTES) {
    return res.status(413).json({ error: "File is too large" });
  }

  // Never use the client's filename as the key
  const key = `pending/${req.user.id}/${crypto.randomUUID()}.${ext}`;

  const upload = await db.upload.create({
    data: { key, userId: req.user.id, contentType, status: "PENDING" },
  });

  const { url, fields } = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: key,
    Conditions: [
      ["content-length-range", 1, MAX_BYTES],
      ["eq", "$Content-Type", contentType],
    ],
    Fields: { "Content-Type": contentType },
    Expires: 60, // seconds
  });

  res.status(201).json({ uploadId: upload.id, url, fields });
});

router.post("/uploads/:id/confirm", requireAuth, async (req, res) => {
  const upload = await db.upload.findFirst({
    where: { id: req.params.id, userId: req.user.id },
  });
  if (!upload) {
    return res.status(404).json({ error: "Upload not found" });
  }
  if (upload.status === "READY") {
    return res.json(upload); // idempotent: safe to call twice
  }

  try {
    const head = await s3.send(
      new HeadObjectCommand({ Bucket: BUCKET, Key: upload.key })
    );
    const updated = await db.upload.update({
      where: { id: upload.id },
      data: { status: "READY", size: head.ContentLength },
    });
    res.json(updated);
  } catch (err) {
    res.status(409).json({ error: "File not found in storage yet" });
  }
});

export default router;

A few decisions here are deliberate:

  • The key is generated server-side. Using the user's filename invites path tricks, collisions and leaked personal info in URLs. Store the original filename in the database if you need to display it.
  • Ownership is checked on confirm. The where clause includes userId, so one user can't confirm or claim another user's upload by guessing an ID.
  • Confirm is idempotent. Mobile networks retry. Users double-click. Calling confirm twice should return the same result, not an error.
  • The pending/ prefix makes cleanup trivial, which I'll come back to below.

The Next.js File Uploads Client

On the Next.js side, this is a client component. I keep the upload logic in a plain function so it can be reused across forms and tested without rendering anything.

"use client";

import { useState } from "react";

const API = process.env.NEXT_PUBLIC_API_URL;

async function uploadFile(file: File) {
  const presignRes = await fetch(`${API}/uploads/presign`, {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ contentType: file.type, size: file.size }),
  });
  const presign = await presignRes.json();
  if (!presignRes.ok) throw new Error(presign.error ?? "Could not start upload");

  const form = new FormData();
  Object.entries(presign.fields as Record<string, string>).forEach(
    ([name, value]) => form.append(name, value)
  );
  form.append("file", file); // must be the LAST field

  const s3Res = await fetch(presign.url, { method: "POST", body: form });
  if (!s3Res.ok) throw new Error("Upload rejected by storage");

  const confirmRes = await fetch(`${API}/uploads/${presign.uploadId}/confirm`, {
    method: "POST",
    credentials: "include",
  });
  if (!confirmRes.ok) throw new Error("Could not confirm upload");
  return confirmRes.json();
}

export function FileUploader({ onUploaded }: { onUploaded: (u: unknown) => void }) {
  const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
  const [message, setMessage] = useState("");

  async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setStatus("uploading");
    try {
      onUploaded(await uploadFile(file));
      setStatus("idle");
    } catch (err) {
      setStatus("error");
      setMessage((err as Error).message);
    }
  }

  return (
    <div>
      <input type="file" accept="image/jpeg,image/png,application/pdf"
        disabled={status === "uploading"} />
      {status === "uploading" && <p>Uploading…</p>}
      {status === "error" && <p role="alert">{message}</p>}
    </div>
  );
}

The accept attribute is a UX nicety, not security. All real validation happens on the backend and in the S3 policy. Also note the order of form fields: S3 ignores anything after the file field, so it has to go last. That one has cost more than one developer an afternoon.

Need a progress bar?

fetch doesn't expose upload progress in browsers. If users upload large files, swap the S3 step for XMLHttpRequest and listen to xhr.upload.onprogress. Keep the presign and confirm calls on fetch.

Production Mistakes I Keep Seeing

In my client work, the upload code itself is rarely the problem. It's the infrastructure and edge cases around it. Here's my checklist before I call an upload feature done.

1. Forgetting bucket CORS

The browser is now talking to S3 directly, so the bucket needs a CORS rule allowing POST from your Next.js domain. The classic symptom: everything works with curl, and the browser shows a vague CORS error. Allow only your real origins, not *.

2. Trusting the content type

The Content-Type condition ensures the declared type matches what you approved, but the browser declares it. A renamed executable can claim to be image/png. For anything sensitive, run a background job after confirm that reads the first bytes of the file (the magic number) and rejects mismatches. For images, re-encoding them through a library like Sharp also strips metadata such as GPS location.

3. Leaving orphaned files

Users close tabs mid-upload. Confirm calls fail. Without cleanup, your bucket slowly fills with junk. Two fixes: an S3 lifecycle rule that deletes objects under pending/ after a day, and moving confirmed files to a permanent prefix (or tagging them) during confirm. Pair it with a scheduled job that deletes stale PENDING database rows.

Don't make the bucket public

It's tempting to flip the bucket to public so image URLs just work. Keep it private. Serve files through presigned GET URLs with short expiry, or through a CDN with origin access control. A public bucket full of user invoices is the kind of mistake that ends client relationships.

4. Using Server Actions for the bytes

Server Actions are great for the presign request if your backend lives inside Next.js. They're the wrong tool for the file itself because of body size limits. Use them for the small JSON calls, and let the browser send bytes to storage.

5. No limits per user

The presign endpoint is effectively a "give me storage" button. Put it behind authentication and rate limiting, and consider a per-user storage quota checked at presign time.

Treat the presign endpoint like a payment endpoint. It hands out a real resource that costs real money, so validate it just as strictly.

One more thing from the freelancing side: upload features are a common place where scope quietly expands. "Just let users upload files" can mean image resizing, virus scanning, progress bars, previews and quotas. When I scope this work for clients, I list each of those explicitly so nobody is surprised later.

Key Takeaways

  • For Next.js file uploads in production, let the browser upload directly to S3 with presigned URLs instead of streaming bytes through your server or Server Actions.
  • Use presigned POST policies so S3 enforces file size and content type, and generate storage keys on the backend, never from user filenames.
  • Model uploads as three steps — presign, upload, confirm — backed by a database row with an owner and a status, and make confirm idempotent.
  • Configure bucket CORS, keep buckets private, and clean up orphaned files with lifecycle rules and scheduled jobs.
  • Don't trust the declared content type for sensitive files; verify file contents in a background job after confirmation.