Skip to main content
ASoc
Tutorial

Next.js File Upload: The Codebase That Ships Zero File Inputs

Four places an upload can live, compared — and the release pipeline this storefront uses instead, with the scanners that fail closed before it.

The ASoc Team9 min read

Next.js has three places to put a file upload: a Server Action, a Route Handler, or a direct browser-to-storage upload that skips your server entirely. The first two are easy to write and both inherit a serverless request-body limit; the third is the one that scales. This codebase takes a fourth route — it has no browser upload at all, and the audit of why is more useful than another formData.get("file") snippet.

$ grep -rn 'type="file"' src/
$                                # no matches

Every file this storefront serves was uploaded by a script on a developer's machine, straight into a private bucket, after passing two scanners that fail closed. Here is that pipeline, what it rules out, and what it tells you about where an upload belongs.

The four options, honestly compared

ApproachBody passes throughPractical size ceilingGood for
Server ActionYour server function~1 MB default action body limit (configurable)Small, form-attached files next to other fields
Route Handler (POST /api/upload)Your server functionPlatform request limit — 4.5 MB on Vercel serverlessUploads needing custom headers or streaming
Presigned / direct-to-storageNothing — browser to storageThe storage provider's limitAnything large, or anything user-facing at volume
Out-of-band (CLI / CI)Nothing at request timeNoneArtifacts only you produce — releases, seeds, assets

The row that decides most real projects is the third. Both server-side options mean the bytes are buffered by a function you pay for per invocation and per second, behind a request limit you do not control. A presigned upload turns your server's job into issuing permission rather than carrying data, which is smaller, cheaper, and does not get larger as the files do.

The fourth row is the one this codebase uses, and it is worth naming as an option because it is easy to forget: if the only files in your system are files you produce, no upload endpoint needs to exist at all. Every endpoint you do not ship is an attack surface you do not have to validate.

The upload that actually ships here

Buyer downloads come from versioned zips in a private Supabase Storage bucket. They get there through one script:

// scripts/release/upload.ts — `npx tsx scripts/release/upload.ts <localZip> <objectPath>`
// Uploads a local zip to the private `releases` bucket via a service-role client.
import { readFileSync } from "node:fs";
import { createClient } from "@supabase/supabase-js";

const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key)
  throw new Error(
    "Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in env.",
  );

async function main() {
  const admin = createClient(url!, key!, {
    auth: { persistSession: false, autoRefreshToken: false },
  });
  const { error } = await admin.storage
    .from("releases")
    .upload(objectPath, readFileSync(localZip), {
      upsert: true,
      contentType: "application/zip",
    });
  if (error) {
    console.error(`UPLOAD FAILED ${objectPath}: ${error.message}`);
    process.exit(1);
  }
  console.log(`uploaded -> releases/${objectPath}`);
}

Four details in that file are the transferable part.

The bucket is private, so upload() is only half the design. A public bucket would make every paid template a URL away from free. Objects go in with the service-role key and come out only as short-lived signed URLs — SIGNED_URL_TTL_SECONDS = 60 in src/lib/download.ts, with the comment "signed URLs are meant to be used immediately, not stored/shared". Decide the read path before you write the write path.

upsert: true makes a re-run idempotent. A release script that fails halfway through a multi-edition product has to be safe to run again. Without upsert, the second attempt collides on the objects the first one already wrote.

contentType is set explicitly. Storage will guess otherwise, and a zip served as application/octet-stream (or worse, something a browser decides to render) is a support ticket.

The session options are off on purpose. persistSession: false, autoRefreshToken: false — a one-shot CLI client has no user to keep signed in and no reason to start a refresh timer.

There is also a gotcha in this file's header worth stealing wholesale, because it cost real debugging time:

// Builds its own service-role client (NOT src/lib/supabase/admin.ts, which is
// `server-only`-guarded and throws when imported outside Next.js under tsx).

The app's admin client carries import "server-only", which is exactly the guard you want in application code — and which makes the module unusable from a plain tsx script. The right fix is the one taken here: let the script construct its own client rather than weakening the guard that protects the app. What the service-role key bypasses is the fuller version of why that guard exists.

The gate before the upload: scanning that fails closed

An upload step that runs unconditionally is the part of a pipeline that leaks. release-template.ts orchestrates the sequence, and the ordering is the control:

run("package.ts", [row.repo, ref, e.objectPath]);
// ...
run("brandscan.ts", [ex]);
run("leakscan.ts", [ex]);
// ...
run("upload.ts", [zip, e.objectPath]);

Package, then scan, then upload — and a scanner that finds something stops that edition rather than the whole run, so one bad edition cannot be quietly swept along with the good ones. leakscan.ts walks the extracted archive and rejects paths that should never ship:

const DENY_PATH_RE: RegExp[] = [
  /(^|\/)\.env$/,
  /(^|\/)\.env\.(?!example|sample|template)/,
  /\.pem$/,
  /\.key$/,
  /(^|\/)id_rsa/,
  /(^|\/)\.git\//,
  /(^|\/)\.vercel\//,
  /(^|\/)\.aws\//,
  /service-account.*\.json$/,
  // ...
];

Note the negative lookahead on the second rule: .env.example is meant to ship, .env.local is never meant to. And one of the entries carries a comment explaining an explicit non-denial — .npmrc is usually benign install config, so it is scanned for real tokens by content rather than blocked by name. A denylist that blocks useful files gets turned off; one that explains its exceptions survives.

Applied to a browser upload, the same shape holds: validate before you persist, and let validation failure be the default outcome rather than an exception path. Reject on extension and on sniffed content type, cap the size before reading the body, and never build a storage path by concatenating a client-supplied filename — objectPath here is derived from catalog data ({slug}/{framework}/{slug}-{framework}-v{version}.zip), not from anything a request carried.

If you do need a browser upload

Nothing above says do not build one — it says know which of the four rows you are in. The short version of doing it well on Next.js:

  • Take the presigned path unless you have a reason not to. Your Route Handler authorizes the request and returns a signed upload URL; the browser PUTs to storage. The 4.5 MB serverless limit stops being your problem, and so does paying function time for transfer.
  • Authorize before you sign, in a function you can test. The pattern this codebase uses for the download direction is a pure authorizeDownload that takes a session plus params and returns a decision, so the rule is unit-testable without a live stack — and the route re-runs it per request rather than trusting any client-side hint. Upload deserves the same shape.
  • Validate size and type server-side even when the browser also checks. A client-side accept attribute is a convenience, never a control.
  • Record the delivery, not the attempt. resolveDownload writes its audit row only after a signed URL is actually issued; a 404 or a rate-limited request records nothing. Mirrored for uploads: log the object that exists, not the one someone asked for.

Gated file downloads is the read-side counterpart to this post — same bucket, same key, opposite direction, and the place where the authorization argument is made in full.

Troubleshooting

SymptomCauseFix
413 / "Body exceeded limit" on deploy, fine locallyPlatform request-body limit (4.5 MB on Vercel serverless); next dev has no such capSwitch to a presigned direct-to-storage upload
Server Action rejects a file that a Route Handler acceptsServer Actions carry their own smaller body limit, separate from the platform'sRaise serverActions.bodySizeLimit, or move the upload off the action
import "server-only" throws in a CLI scriptThe module is guarded for the app, and tsx is not Next.jsBuild a separate client in the script; don't remove the guard
Re-running a failed upload script errors on existing objectsNo upsert, so the second run collidesupsert: true, and make the object path deterministic
Uploaded file downloads as octet-stream or renders in the tabcontentType not set at upload timePass it explicitly; storage guesses badly
Private files are publicly reachableBucket is public, or a signed URL with a long TTL got sharedPrivate bucket plus short-lived signed URLs — 60 seconds is plenty
A user's filename ends up in the storage pathPath built by concatenating client inputDerive the path from server-side data; treat the filename as a display label only

Frequently asked questions

What is the maximum file size I can upload to a Next.js API route? On Vercel's serverless functions the request body limit is 4.5 MB, and Server Actions apply a smaller default on top of that. Both are reasons to prefer a presigned upload straight to storage for anything user-facing — there the ceiling is the storage provider's, and your function never touches the bytes.

Server Action or Route Handler for uploads? A Server Action if the file arrives as one field of a form you are already handling and is small. A Route Handler when you need control over headers, streaming, or the response shape. For anything large, neither — sign a URL and get out of the way.

Do I need a service like UploadThing or Uploadcare? They buy you a client component, presigning, and a CDN for a per-project cost. If your storage provider already signs upload URLs — Supabase Storage, S3, R2 all do — you are one Route Handler away from the same thing, and you keep the files.

How do I stop someone uploading a file that isn't what it claims to be? Check the sniffed content type server-side rather than trusting the extension or the client-supplied MIME type, cap the size before you read the body, and store the result under a path you derived. The pipeline above adds one more layer worth copying: scan the stored artifact and refuse to publish on any finding.

Templates in this post

ASoc Remit is a payments-platform marketing site with a transactions dashboard preview and a comparison pricing table. ASoc Script is an AI copywriting SaaS site covering features, integrations, templates and pricing. ASoc Seeker is an AI keyword-research landing page with a benefit grid, use cases and two-tier pricing. All three are front ends for products whose file handling, like this one's, belongs on the server side of the line.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial13 min read

Gated File Downloads in Next.js: The Order the Checks Must Run In

Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.

Read more
Tutorial13 min read

Next.js Image Optimization Without next/image: 255 KiB to 33 KiB

A closed image set does not need a request-time optimiser. The sharp build script, the plain-img markup, and the lazy LCP image that cost us 1.67s of load delay.

Read more