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.
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
| Approach | Body passes through | Practical size ceiling | Good for |
|---|---|---|---|
| Server Action | Your server function | ~1 MB default action body limit (configurable) | Small, form-attached files next to other fields |
Route Handler (POST /api/upload) | Your server function | Platform request limit — 4.5 MB on Vercel serverless | Uploads needing custom headers or streaming |
| Presigned / direct-to-storage | Nothing — browser to storage | The storage provider's limit | Anything large, or anything user-facing at volume |
| Out-of-band (CLI / CI) | Nothing at request time | None | Artifacts 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
authorizeDownloadthat 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
acceptattribute is a convenience, never a control. - Record the delivery, not the attempt.
resolveDownloadwrites 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
| Symptom | Cause | Fix |
|---|---|---|
413 / "Body exceeded limit" on deploy, fine locally | Platform request-body limit (4.5 MB on Vercel serverless); next dev has no such cap | Switch to a presigned direct-to-storage upload |
| Server Action rejects a file that a Route Handler accepts | Server Actions carry their own smaller body limit, separate from the platform's | Raise serverActions.bodySizeLimit, or move the upload off the action |
import "server-only" throws in a CLI script | The module is guarded for the app, and tsx is not Next.js | Build a separate client in the script; don't remove the guard |
| Re-running a failed upload script errors on existing objects | No upsert, so the second run collides | upsert: true, and make the object path deterministic |
Uploaded file downloads as octet-stream or renders in the tab | contentType not set at upload time | Pass it explicitly; storage guesses badly |
| Private files are publicly reachable | Bucket is public, or a signed URL with a long TTL got shared | Private bucket plus short-lived signed URLs — 60 seconds is plenty |
| A user's filename ends up in the storage path | Path built by concatenating client input | Derive 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.
