The first upload feature most people build sends the file to their own server, and the server forwards it to S3. It's simple and it works, right up until someone uploads a 300 MB video.
Now your server holds that video in memory or on disk, pays for the bandwidth twice, and runs into whatever request body limit your host has. On serverless platforms that limit is usually a few megabytes. Next.js server actions stop at 1 MB unless you raise it.
The fix is a presigned upload: your server never touches the file bytes. It only signs a request, and the browser sends the file straight to the bucket. This post walks through how that works with Amazon S3 and Cloudflare R2, the mistakes that are easy to make, and how UploadCN handles each of them. I built that path, so I've hit most of these myself.
The flow, step by step
- The browser checks the file and asks your route to sign an upload, sending only the name, type and size.
- Your route checks who's asking and whether the file is allowed, picks the object key, and returns a signed
PUTURL. - The browser sends the bytes directly to the bucket, tracking progress along the way.
- The browser tells your route it's done. The route confirms the object exists, then saves the record.
Your server handles three tiny JSON requests. It stays fast and cheap, and it never hits a body size limit, however big the file is.
What the route has to enforce
The browser is not on your side here. Anything it sends can be changed, so the signing route is where the real rules live:
- Authentication. Run your auth check before every action, not just the first one.
- Size. Check the declared size before signing, and sign the
Content-Lengthinto the URL. If the browser then sends a bigger body, S3 rejects it. - Type. Same idea: check it, then lock the
Content-Typeinto the signature. - Keys. Generate object keys on the server. If the browser picks the path, someone will eventually pick
../another-user/avatar.png. - Existence. When the browser says "done", check the object with a
HEADrequest before you write anything to your database. Otherwise anyone can register a file they never uploaded.
With UploadCN, all of that lives in one route file. Add it with npx shadcn@latest add @uploadcn/upload-route and you get this, ready for your own auth:
import { UploadRouteError, createUploadRoute, s3Storage } from "@uploadcn/server"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
export const { POST } = createUploadRoute({
storage: s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
}),
maxFileSize: 500 * 1024 * 1024,
allowedTypes: ["image/*", "application/pdf"],
async authorize({ key }) {
const session = await auth()
if (!session) throw new UploadRouteError("Unauthorized", 401)
// Multipart actions carry the key: make sure it belongs to this user.
if (key && !key.startsWith(`${session.user.id}/`)) throw new UploadRouteError("Forbidden", 403)
return session.user
},
getKey: ({ auth, file }) => `${auth.id}/${crypto.randomUUID()}/${file.name}`,
async onUploadComplete({ auth, key, file }) {
return db.file.create({ data: { userId: auth.id, key, name: file.name, size: file.size } })
},
})@uploadcn/server is built on Web standard Request and Response, so the route runs on both the Node.js and Edge runtimes.
The CORS setting that breaks multipart
Because the browser talks to your bucket directly, the bucket needs a CORS policy that allows it:
[
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Most guides stop at AllowedOrigins and AllowedMethods. The line that matters most is ExposeHeaders: ["ETag"].
When a large file is uploaded in parts, S3 returns an ETag for each part, and the final "complete" request has to list them all. Browsers hide response headers from JavaScript unless CORS exposes them. So without that line, every part uploads successfully, the progress bar reaches 100%, and then the last request fails with an error that tells you almost nothing. I lost an evening to this. Now it's the first thing I check.
One PUT or multipart?
A single PUT is perfect for small files. For big ones you want multipart:
| | Single PUT | Multipart | | --- | --- | --- | | Max size | 5 GB (S3) | 5 TB | | Pause and resume | Restarts from 0 | Continues from the last finished part | | Parallelism | One stream | Several parts at once | | Requests | 3 | 2 + parts × 2 |
You don't have to choose by hand. UploadCN's s3Adapter switches to multipart automatically at a threshold, 64 MB by default, and you can tune it:
import { MiB, s3Adapter } from "@uploadcn/core"
const adapter = s3Adapter({
endpoint: "/api/upload",
multipart: { threshold: 64 * MiB, partSize: 8 * MiB, concurrency: 4 },
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
})The headers go to your route only, never to S3, so it's a safe place for a bearer token. Parts are signed one at a time, uploaded in parallel and retried individually. One flaky part doesn't sink the whole file.
Retries that don't make things worse
Retrying everything sounds safe, but it isn't. Retrying a 403 just makes the user wait longer for an error that was never going to change. The rules I settled on:
- Network errors, timeouts,
408,425,429and any5xxare retried with exponential backoff. - Other
4xxresponses fail right away, with the reason shown next to the file. - When the server sends a
Retry-Afterheader, the engine waits exactly that long.
Surviving a dropped connection, or a reload
Pausing a multipart upload is cheap, because resuming just carries on from the last finished part. UploadCN only shows a pause button when the upload can really resume, so nobody is offered a pause that would silently restart a 2 GB file from zero.
Reloads are harder. The tab is gone, and so is the File object. To survive that, you need to persist the upload, bytes included:
const [persistence] = React.useState(() => createIndexedDBPersistence())
const uploader = useUploader({ adapter, persistence, restore: true })Queued and in-progress uploads are written to IndexedDB along with the multipart state. After a reload they come back paused, and one call to uploader.resume(), or a click on resume, carries on from the last part. A few details are worth knowing:
- Browsers may clear IndexedDB when the disk is under pressure.
requestPersistentStorage()asks for durable storage. - With two tabs open, each running upload holds a Web Lock, so the second tab won't try to resume an upload the first one is already sending.
- Saved uploads older than 7 days are dropped.
Cloudflare R2: same browser code, different server
R2 speaks the S3 API, so nothing changes in the browser. r2Adapter is simply an alias of s3Adapter. The differences are all on the server:
import { createUploadRoute, r2Storage } from "@uploadcn/server"
export const { POST } = createUploadRoute({
storage: r2Storage({
accountId: process.env.R2_ACCOUNT_ID!,
bucket: process.env.R2_BUCKET!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
// A custom domain or r2.dev URL for reading objects:
publicUrl: process.env.R2_PUBLIC_URL,
}),
})r2Storage takes care of the R2-specific parts: the https://<account>.r2.cloudflarestorage.com endpoint, the auto region and path-style URLs. Buckets restricted to a jurisdiction take jurisdiction: "eu". The route signs with Web Crypto, so the same file also runs on Cloudflare Workers.
R2's CORS policy lives under Settings → CORS policy in the dashboard. It's the same JSON as above, ExposeHeaders included.
Why pick R2 at all? It doesn't charge for egress, which matters a lot once people start downloading what they uploaded. For anything heavy on media, that's often the deciding factor.
Any other S3-compatible storage
MinIO, Backblaze B2, DigitalOcean Spaces and Wasabi all work through s3Storage with a custom endpoint. Path-style URLs switch on automatically:
s3Storage({
endpoint: "https://s3.us-west-004.backblazeb2.com",
region: "us-west-004",
bucket: "uploads",
accessKeyId: process.env.B2_KEY_ID!,
secretAccessKey: process.env.B2_APPLICATION_KEY!,
})Clean up abandoned uploads
Here's a cost nobody warns you about. When someone closes the tab halfway through a multipart upload and never comes back, the parts already uploaded stay in your bucket, invisible in the normal object list, and you're billed for them.
Cancelled uploads are aborted automatically. Abandoned ones need a lifecycle rule. On S3:
{
"Rules": [
{
"ID": "abort-incomplete-multipart-uploads",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 3 }
}
]
}aws s3api put-bucket-lifecycle-configuration --bucket my-uploads --lifecycle-configuration file://lifecycle.jsonR2 has the same option in the bucket's object lifecycle rules. Set it once and forget about it.
The short version
- Sign on the server, send bytes from the browser.
- Enforce auth, size, type and keys in the route, and check the object exists before you trust it.
- Expose
ETagin CORS, or multipart will fail at the very last step. - Use multipart for big files, retry only what can succeed, and persist uploads if reloads matter.
- Add a lifecycle rule for abandoned parts.
If you'd rather not build all of that again, UploadCN does it for you: install a block with the shadcn CLI and add one route. My step-by-step setup guide for shadcn/ui and Next.js covers the rest, and the UploadCN case study shows how the whole project came together.