Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Build a Next.js file uploader with Cloudflare R2

Signed-in users upload from a Next.js app straight to a private R2 bucket with progress, then download only their own files through short-lived signed URLs.

Your server never carries the file bytes. One Next.js route checks the session, mints a key under that user’s prefix, and signs a short-lived PUT URL, and the browser sends the file to R2 directly while it reports progress. Downloads work the same way: the route checks the session and redirects to a signed GET URL, so each user reaches their own files and nobody else’s.

The catch is size limits. R2 doesn’t implement S3’s POST upload policies, so a presigned URL can’t cap how many bytes a client sends. If you need a hard limit, you either route uploads through your server or check each object after it lands. Limit upload size covers both.

Before you start

  • A Cloudflare account with R2 enabled, a bucket (this guide calls it uploads), and an R2 API token with Object Read & Write on that bucket. The token gives you an access key ID and secret access key; your account ID is on the R2 overview page.
  • A Next.js App Router app with an auth library that can resolve the signed-in user on the server.
  • Written against files-sdk 3.0, Next.js 16.4, and React 19.3.
npm install files-sdk
pnpm add files-sdk
yarn add files-sdk
bun add files-sdk
nub add files-sdk
aube add files-sdk

That’s the only package. The R2 adapter’s client: "fetch" engine signs requests with aws4fetch and Web Crypto, so you don’t need any @aws-sdk/* packages for this setup.

How an upload flows

files-sdk/react and the gateway in files-sdk/api share a three-step protocol. You don’t call these steps yourself; upload(file) runs them. Knowing them makes the setup and the errors easier to follow.

Step Request What happens
1. Presign Browser → POST /api/files Your authorize hook runs. The gateway mints a key like users/42/3f1c….pdf, asks R2 for a presigned PUT URL, and signs an HMAC token for the key.
2. Upload Browser → R2 The browser PUTs the file to the presigned URL with XMLHttpRequest, which reports upload progress. This is a cross-origin request, so the bucket needs a CORS rule.
3. Complete Browser → POST /api/files authorize runs again. The gateway verifies the token, checks that its key is under this user’s prefix, heads the object in R2, and returns its key, size, content type, and ETag.

The server chooses the key, so a client can’t overwrite another user’s file or pick a path outside its prefix. A token is also tied to the prefix it was minted under: complete refuses another user’s token with upload token was not issued for this caller, without revealing the key.

Add the credentials

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
# Signs the presign → complete token. Generate with: openssl rand -hex 32
FILES_API_SECRET=a-long-random-string

The R2 adapter reads the three R2_* variables itself. FILES_API_SECRET matters more than it looks. Without it, the gateway falls back to a random secret per process and logs a warning. That works on one dev server, but in production the presign and complete requests can land on different instances. The complete step then fails with upload token signature.

None of these variables has a NEXT_PUBLIC_ prefix, and none should. They stay on the server.

Create the storage instance

import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";

export const files = createFiles({
  adapter: r2({ bucket: "uploads", client: "fetch" }),
});

Import this module only from server code: route handlers, server actions, and server components. If you use the server-only package, add import "server-only" at the top so an accidental client import fails the build.

Mount the gateway

One route handler serves every file operation the browser needs. authorize runs before each one: it rejects anonymous requests, allows only the operations your UI uses, and scopes every key to the signed-in user.

import { FilesError } from "files-sdk";
import { createFilesRouter, type FilesOperation } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";

import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";

// The verbs this app's UI calls. Everything else is refused.
const ALLOWED = new Set<FilesOperation>([
  "upload",
  "list",
  "head",
  "url",
  "download",
  "delete",
]);

const router = createFilesRouter({
  files,
  authorize: async ({ operation, req }) => {
    const session = await getSession(req.headers);
    if (!session) {
      throw new FilesError("Unauthorized", "Sign in to manage files");
    }
    if (!ALLOWED.has(operation)) {
      throw new FilesError("ReadOnly", `${operation} is not allowed`);
    }
    return {
      keyPrefix: `users/${session.user.id}/`,
      maxExpiresIn: 300,
    };
  },
});

export const { GET, POST, PUT } = createRouteHandler(router);

getSession stands in for your auth library: Auth.js’s auth(), Clerk’s auth(), Better Auth’s auth.api.getSession({ headers }), or your own cookie check. This guide assumes it takes request headers and returns { user: { id: string } } or null.

A few details in that file are deliberate:

  • Throw a FilesError, not a plain Error. The gateway maps Unauthorized to 401 and ReadOnly to 403. A plain Error becomes a 500.
  • keyPrefix is enforced on the server. When the browser asks for report.pdf, the gateway reads users/42/report.pdf. Keys that try to climb out with .. are rejected, and list results come back with the prefix stripped. See Authorization for the rest of the constraint object.
  • maxExpiresIn: 300 caps how long any signed URL from this route lives. The gateway’s own default is also 300 seconds, but a client can ask for longer. The cap is what stops it.
  • Origins. State-changing requests must come from the route’s own origin by default. That’s right when the page and the API share a domain; add allowedOrigins only if they don’t.

Let the browser PUT to R2

Step 2 is a cross-origin PUT from your site to <account-id>.r2.cloudflarestorage.com, so the bucket needs a CORS rule allowing it. In the Cloudflare dashboard, open the bucket, go to Settings → CORS Policy, and add:

[
  {
    "AllowedOrigins": ["http://localhost:3000", "https://app.example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type"],
    "MaxAgeSeconds": 3600
  }
]

Each part of this rule matches something the upload sends:

  • PUT is the only method the browser uses against R2 in this guide. Downloads are top-level navigations to a signed URL, and those aren’t CORS requests. Add GET only if you call download() from JavaScript, because that fetch follows the redirect to R2.
  • Content-Type is the one header the upload sends. The fetch engine signs the file’s type into the URL, so the browser has to send that exact header, and R2 rejects a different one.
  • AllowedOrigins lists exact origins (scheme, host, and port, no path). Cloudflare allows wildcards, but CORS failures on R2 are rarely fixed by widening this list. Don’t reach for *.

Cloudflare’s CORS docs say changes can take up to 30 seconds to apply. To check the rule before you touch the UI, send the preflight request yourself:

curl -i -X OPTIONS "https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com/uploads/cors-check" \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type"

A matching rule answers with Access-Control-Allow-Origin: http://localhost:3000. If that header is missing, fix the rule before you go further.

Build the upload page

useFiles talks to /api/files by default. Its upload(file) runs all three steps and keeps per-file state you can render. useList fetches the current user’s files and refetches on demand.

"use client";

import type { ChangeEvent } from "react";
import { useFiles, useList } from "files-sdk/react";

export function FileManager() {
  const files = useFiles();
  const list = useList({ limit: 100 });

  async function onSelect(event: ChangeEvent<HTMLInputElement>) {
    const selected = [...(event.target.files ?? [])];
    event.target.value = "";
    // Each rejection is also recorded on its `files.uploads` entry.
    await Promise.allSettled(selected.map((file) => files.upload(file)));
    list.refetch();
  }

  return (
    <section>
      <label>
        Upload files
        <input multiple onChange={onSelect} type="file" />
      </label>

      <ul>
        {files.uploads.map((upload, index) => (
          <li key={`${upload.name}-${index}`}>
            {upload.name}: {upload.status}
            {upload.status === "uploading" &&
              ` ${Math.round(upload.progress * 100)}%`}
            {upload.error && ` (${upload.error.message})`}
          </li>
        ))}
      </ul>

      {list.error && <p role="alert">{list.error.message}</p>}
      <ul>
        {list.data?.items.map((item) => (
          <li key={item.key}>
            <a
              href={`/api/files?op=download&key=${encodeURIComponent(item.key)}`}
            >
              {item.key}
            </a>{" "}
            ({item.size} bytes)
          </li>
        ))}
      </ul>
    </section>
  );
}

Render it from a server component that’s already behind your sign-in:

import { FileManager } from "./file-manager";

export default function FilesPage() {
  return <FileManager />;
}

files.uploads gets one entry per file. Each entry moves from "uploading" to "success", "error" (with error set to a FilesError), or "aborted", and progress runs from 0 to 1. files.abort() cancels everything in flight, and files.reset() clears finished entries.

The listed keys are relative. item.key is 3f1c….pdf, not users/42/3f1c….pdf, because the gateway strips the prefix on the way out.

Download privately

The download link in that component is a plain anchor:

/api/files?op=download&key=3f1c….pdf

When the user clicks it, the browser sends their session cookie with the same-origin request. authorize runs for the download operation, and the gateway responds with a 302 to a presigned R2 GET URL that expires in at most 300 seconds. The bucket stays private and you never store a public URL. A link copied into another browser is just a request to your route, which refuses it without a session.

The gateway signs the URL with a response-content-disposition=attachment override, asking R2 to send Content-Disposition: attachment so the browser saves the file instead of rendering it at R2’s origin. Cloudflare’s S3 compatibility table doesn’t list the response-* overrides, so confirm the header once on your bucket with curl -sI "<signed url>" before you rely on it. To display images inline, call files.url(key) from the client and use the result as an <img src>. To render other types inline, have authorize return disposition: "inline" for the keys and content types you trust. The gateway docs explain why attachment is the default.

Limit upload size

A presigned PUT URL on R2 signs the key, the content type, and the expiry, but not the size. R2 doesn’t support presigned POST, which is the S3 feature that carries a content-length-range policy. Files SDK won’t pretend otherwise: signedUploadUrl() on R2 throws if you pass maxSize. Without a limit of your own, a client can send any single-part upload R2 accepts, which is up to 5 GiB.

Checking file.size in the browser before calling upload gives users a fast error message, but anyone can skip it. For a limit that holds, pick one of these two options.

Option 1: route uploads through your server

Set maxUploadSize on the gateway:

const router = createFilesRouter({
  files,
  maxUploadSize: 25 * 1024 * 1024, // 25 MiB
  authorize,
});

On R2 this changes the upload path, not just the limit. A file whose declared size is already over the limit is refused at presign with 422 (upload exceeds maxUploadSize) before anything moves. For the rest, the gateway sees that R2 can’t enforce maxSize on a presigned URL (files.capabilities.signedUpload.maxSize is false) and returns its proxy target instead. The browser then PUTs the bytes to /api/files. The gateway counts the bytes as they stream through and aborts past the limit, so an oversized file never finishes landing in R2.

The cost is that every byte now passes through your server. Two consequences:

  • On Vercel, function request bodies are capped at 4.5 MB, so proxied uploads over that size fail. Fix Vercel’s 413 upload error covers the platform side.
  • The fetch engine buffers a streamed body in memory before its single PUT. For large proxied uploads on a long-running Node server, switch the adapter to client: "aws-sdk" and install @aws-sdk/client-s3, @aws-sdk/lib-storage, @aws-sdk/s3-presigned-post, and @aws-sdk/s3-request-presigner. lib-storage streams the body to R2 as a multipart upload.

Option 2: keep direct uploads and check after they land

Keep the presigned path and check each object before your app relies on it. The gateway’s complete step deletes an object over maxUploadSize, but on R2 setting maxUploadSize is what switches uploads to the proxy, so on this path complete has no limit to check. Do the check yourself when the client hands the key to your app, for example when it attaches the file to a record:

"use server";

import { headers } from "next/headers";

import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";

const MAX_BYTES = 25 * 1024 * 1024;

export async function attachUpload(key: string) {
  const session = await getSession(await headers());
  if (!session) {
    throw new Error("Not signed in");
  }
  // Rebuild the full key server-side; never trust a client-supplied prefix.
  const fullKey = `users/${session.user.id}/${key}`;
  const stored = await files.head(fullKey);
  if (stored.size > MAX_BYTES) {
    await files.delete(fullKey);
    throw new Error("File is larger than 25 MiB");
  }
  // Save fullKey, stored.size, and stored.contentType with the record here.
  return { key, size: stored.size, contentType: stored.contentType };
}

This doesn’t stop the upload. The bytes reach R2 before your check runs, so you pay for the write, and the object exists until you delete it. It does stop an oversized file from entering your app. Objects nobody ever attaches can be cleaned up with an object lifecycle rule on a staging prefix.

Enforce file-size and content-type limits on presigned uploads compares what S3, R2, and Vercel Blob can each enforce, and covers checking the actual bytes rather than the claimed content type.

Deploy

  • Set R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and FILES_API_SECRET in your host’s environment for every environment that uploads. Preview deployments need them too.
  • Add each deployed origin to the bucket’s AllowedOrigins, including preview domains if you test uploads there.
  • On Vercel, direct uploads never touch the function’s 4.5 MB body limit. Only the proxied path from option 1 does.

Troubleshooting

network error during upload in the browser, and a CORS error in the console. The browser blocked the PUT to R2. Usually the origin isn’t in AllowedOrigins, or Content-Type is missing from AllowedHeaders. Run the curl preflight above. An expired presigned URL can also look like this: R2 answers 403 without CORS headers, so the browser reports a CORS failure. Fix Cloudflare R2 CORS and presigned URL 403 errors walks through each cause.

upload failed (403). R2 rejected the signature. Check that the bucket name and account ID on the server match the bucket you configured, and that the request reached R2 well inside the URL’s lifetime.

upload token signature on complete. Presign and complete were verified with different secrets. Set FILES_API_SECRET to the same value everywhere the route runs.

401 from /api/files. getSession returned null for that request. Check that the session cookie reaches route handlers, for example that it isn’t scoped to a different path.

The listed keys don’t match what’s in the bucket. That’s the prefix being stripped. The bucket holds users/<id>/<key>, and the browser sees <key>.

Last updated on

Was this page helpful?