FilesIntermediate~1-2 hours

Handle file uploads

Almost every app eventually needs users to upload something — profile pictures, PDFs, CSVs, video. Done wrong it blows up your server, leaks private files, or lets someone upload a 2GB payload. This is how to do it so it scales and stays secure.

the approach

Do not stream file bytes through your own app server. In 2026 the default pattern is direct-to-storage uploads: your server issues a short-lived presigned URL, the browser uploads the file straight to object storage (Cloudflare R2, S3, Supabase Storage), and your server only ever handles small JSON — the auth check, validation rules, and a metadata row in your database. Files are private by default and served through signed download URLs. Reach for a managed uploader like UploadThing only if you want to skip the plumbing; reach for resumable (tus/Uppy) only when files are large.

Step by step

  1. 1

    Pick object storage, not your app server or DB

    Use Cloudflare R2, AWS S3, or Supabase Storage. Never store binary blobs in Postgres and never keep them on a serverless filesystem — it's ephemeral. R2 is a good default: S3-compatible API with zero egress fees.

  2. 2

    Issue presigned upload URLs from your server

    Add an authenticated endpoint that checks the user is allowed to upload, validates the declared content-type and size, generates its OWN random key (never the client's filename), and returns a short-lived (30-60s) presigned PUT URL. This is the security boundary — enforce limits here, not in the browser.

  3. 3

    Upload from the browser straight to storage

    The client does a plain fetch PUT of the raw File to the presigned URL. Bytes never touch your server, so you dodge serverless body-size limits (Vercel caps request bodies around 4.5MB) and function timeouts.

  4. 4

    Record metadata after the upload confirms

    On success, call back to your server to write a row (key, owner, size, mime, created_at) with Drizzle ORM. The file object and the DB row are separate systems — without this step you get orphaned files nobody can find.

  5. 5

    Serve files privately with signed download URLs

    Keep the bucket private. When someone needs a file, generate a short-lived signed GET URL scoped to that object. Public buckets are how private invoices end up indexed by Google.

  6. 6

    Validate and sniff on the server

    Re-check size and MIME server-side, and for images sniff the actual bytes (magic numbers) rather than trusting the Content-Type header. Cap the file size, restrict the allowed types to what you actually need, and consider virus scanning for user-shared files.

  7. 7

    Add resumable uploads only if files are large

    For anything over ~50MB or flaky-network scenarios (video, mobile), use Uppy with the tus protocol against a resumable-capable backend so a dropped connection doesn't restart the whole upload. Skip this complexity for avatars and PDFs.

Starter snippet

typescript
// app/api/upload-url/route.ts — issue a presigned PUT for Cloudflare R2 (S3-compatible)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "crypto";

const s3 = new S3Client({
  region: "auto",
  endpoint: process.env.R2_ENDPOINT, // R2 / S3 / any S3-compatible store
  credentials: { accessKeyId: process.env.R2_KEY!, secretAccessKey: process.env.R2_SECRET! },
});

const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
const ALLOWED = new Set(["image/png", "image/jpeg", "application/pdf"]);

export async function POST(req: Request) {
  // TODO: check the user is authenticated here before issuing a URL
  const { contentType, size } = await req.json();
  if (!ALLOWED.has(contentType) || size > MAX_BYTES) {
    return Response.json({ error: "File type or size not allowed" }, { status: 400 });
  }
  const key = `uploads/${randomUUID()}`; // generate our own key — never trust client filenames
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: "my-bucket", Key: key, ContentType: contentType }),
    { expiresIn: 60 }
  );
  return Response.json({ url, key }); // browser PUTs the file straight to `url`, then reports `key` back
}

✕ Watch out for

  • Proxying file bytes through your own serverless function. You'll hit request body limits (~4.5MB on Vercel), function timeouts, and a surprise bandwidth bill. Use presigned direct-to-storage uploads instead.
  • Trusting the client's Content-Type header or filename. Both are trivially spoofed — a renamed script can claim to be image/png. Generate your own storage key and sniff the real bytes server-side.
  • Leaving the bucket public. 'Public by default' is how private files get scraped. Keep storage private and hand out short-lived signed download URLs.
  • Only enforcing the size limit in the browser. The client can lie about `size`; a presigned URL alone won't stop a bigger upload unless you constrain it. Re-validate and cap server-side.
  • Never writing the metadata row. The upload to storage succeeds but your app has no record of it, so you get orphaned files and broken references. Confirm the DB write on upload completion.

✓ Pro tips

  • Tell your AI agent explicitly: 'Use presigned direct-to-storage uploads, not a multipart form POST through the server.' Left vague, agents default to the naive multer/formidable pattern that breaks on serverless. Then check the generated route actually has a size cap, a MIME allowlist, and generates its own key with randomUUID.
  • Test the failure cases, not just the happy path: upload a file one byte over your limit, and rename an .exe to .png. If either gets through, your validation is cosmetic.
  • Keep presigned URL expiry short — 30 to 60 seconds is plenty for the browser to start the PUT — so a leaked URL is useless minutes later.
  • Ask the agent to wire an 'upload complete' callback that writes the DB row, and confirm there's a cleanup path for files that upload but never get confirmed.