8 min read

Direct-to-S3 and Cloudflare R2 uploads in Next.js, without the pitfalls

Everything I learned building the S3 and R2 upload path in UploadCN: why files should skip your server, what the signing route must enforce, the CORS setting that breaks multipart, and how to make uploads survive a dropped connection or a reload.

  • UploadCN
  • Next.js
  • AWS S3
  • Cloudflare R2
  • File uploads
Direct-to-S3 and Cloudflare R2 uploads in Next.js, without the pitfalls, an article by Muhammad Hanzala

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

  1. The browser checks the file and asks your route to sign an upload, sending only the name, type and size.
  2. Your route checks who's asking and whether the file is allowed, picks the object key, and returns a signed PUT URL.
  3. The browser sends the bytes directly to the bucket, tracking progress along the way.
  4. 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:

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:

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:

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.json

R2 has the same option in the bucket's object lifecycle rules. Set it once and forget about it.

The short version

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.

Frequently asked questions

What is a presigned upload?

A presigned upload is a short-lived URL signed by your server that lets the browser upload a file directly to S3 or another bucket. Your server never handles the file bytes, so it avoids request body limits and double bandwidth.

Why does my S3 multipart upload fail at the end?

Most often the bucket's CORS policy doesn't expose the ETag header. The browser needs each part's ETag to complete the upload, so add ExposeHeaders ETag to the CORS rules.

When should I use multipart instead of a single PUT?

Use multipart for large files. A single PUT is limited to 5 GB on S3 and restarts from zero after a failure, while multipart supports up to 5 TB, uploads parts in parallel and resumes from the last finished part. UploadCN switches automatically at 64 MB by default.

Is Cloudflare R2 different from S3 for browser uploads?

The browser code is identical because R2 implements the S3 API. Only the server configuration changes: the account endpoint, the auto region and path-style URLs. R2 also does not charge egress fees.

Case studyUploadCN: how I designed, built and shipped it

Building something that needs this kind of care?

I take products from idea to production.

Start a project