Skip to content
Cloud & Infra Beginner Tutorial

Store and Serve User Uploads with Cloudflare R2 and Presigned URLs in Node.js

Build a Next.js upload flow that signs direct-to-R2 PUT requests and a Cloudflare Worker that serves the files back out from your own domain.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 12, 2026 · 9 min read
Store and Serve User Uploads with Cloudflare R2 and Presigned URLs in Node.js

What you'll build

A Next.js app that generates short-lived, server-signed URLs for direct browser-to-R2 uploads, and a Cloudflare Worker that serves those files back out from a custom domain like files.yourapp.com. No egress fees, no proxying uploads through your own server.

Prerequisites

  • A Cloudflare account (free tier works fine for R2's storage allowance)
  • Node.js 18.17+ and a Next.js 14 app (App Router)
  • wrangler CLI: npm install -g wrangler (or use npx wrangler), logged in via wrangler login
  • A domain added to Cloudflare (for the custom Worker route), optional if you just want to test on the workers.dev subdomain first

1. Create the R2 bucket

Dashboard: R2 > Overview > Create bucket. Or from the CLI:

npx wrangler r2 bucket create my-app-uploads

2. Generate an S3-compatible API token

In the dashboard, go to R2 > Manage R2 API Tokens > Create API Token. Scope permissions to Object Read & Write and, ideally, restrict it to the one bucket. Copy the Access Key ID and Secret Access Key immediately, they're shown once. Your Account ID is visible on the R2 overview page in the right sidebar.

Add these to .env.local in your Next.js project:

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=my-app-uploads

Never commit this file. R2's S3 API means you use the standard AWS SDK, just pointed at a different endpoint.

3. Configure CORS on the bucket

Browsers will PUT directly to R2, so the bucket needs a CORS policy or you'll get blocked before the request even reaches Cloudflare. Go to your bucket's Settings > CORS Policy and add:

[
  {
    "AllowedOrigins": ["http://localhost:3000", "https://yourapp.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]

Only allow PUT here. Downloads go through the Worker in step 7, not straight from the bucket, so there's no reason to open up GET on the bucket's CORS policy too.

4. Install the AWS SDK in your Next.js app

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

5. Write the presigned URL API route

Create app/api/upload/route.js:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});

export async function POST(req) {
  const { filename, contentType } = await req.json();

  if (!filename || !contentType) {
    return Response.json({ error: "filename and contentType required" }, { status: 400 });
  }

  const key = `uploads/${randomUUID()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(s3, command, { expiresIn: 300 });

  return Response.json({ url, key });
}

randomUUID comes from Node's built-in crypto module, imported explicitly rather than relying on a global. That way it works the same whether this route runs on the Node.js runtime (the App Router default) or you later switch it to the Edge runtime. expiresIn: 300 gives the client 5 minutes to actually perform the PUT before the signature goes stale. region: "auto" is the correct value for R2, it doesn't use AWS regions.

6. Upload from the browser

async function uploadFile(file) {
  const res = await fetch("/api/upload", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  });
  const { url, key } = await res.json();

  await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  return key; // save this in your database
}

Note the Content-Type on the actual PUT has to match exactly what you signed for in PutObjectCommand. Mismatch here is the #1 cause of signature errors.

7. Serve files with a Worker on a custom domain

R2 buckets can be attached to a custom domain directly, but routing through a Worker gives you control over caching, auth checks, and headers. wrangler init is deprecated, so just set the folder up by hand:

mkdir r2-file-server && cd r2-file-server
npm init -y
npm install -D wrangler
mkdir src

Create wrangler.toml:

name = "r2-file-server"
main = "src/index.js"
compatibility_date = "2024-09-23"

[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "my-app-uploads"

routes = [
  { pattern = "files.yourapp.com", custom_domain = true }
]

Use today's date (or later) for compatibility_date. Note the route pattern is just the hostname, no /* wildcard. Custom Domains route every request for that hostname to the Worker automatically, and wrangler will reject a pattern that includes a path.

Create src/index.js:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = decodeURIComponent(url.pathname.slice(1));

    if (!key) return new Response("Not found", { status: 404 });

    const object = await env.UPLOADS.get(key);
    if (!object) return new Response("Not found", { status: 404 });

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set("etag", object.httpEtag);
    headers.set("cache-control", "public, max-age=31536000, immutable");

    return new Response(object.body, { headers });
  },
};

decodeURIComponent matters here: filenames with spaces or special characters get percent-encoded in the URL, but the key you stored in R2 is the raw string. Skip the decode and you'll get 404s on any file whose name wasn't already URL-safe.

Deploy:

npx wrangler deploy

custom_domain = true requires files.yourapp.com to already be a zone (or subdomain of one) on your Cloudflare account. If you don't have a domain handy yet, drop the routes block and hit the *.workers.dev URL wrangler prints instead.

Verify it works

  1. Upload a file through your app, confirm it shows up under R2 > my-app-uploads in the dashboard.
  2. Request https://files.yourapp.com/uploads/<the-key-you-got-back> in a browser. You should get the file back with a cache-control: public, max-age=31536000, immutable header.
  3. Check npx wrangler tail while requesting the file to confirm the Worker is actually handling it, not falling through to a 404 page.

Troubleshooting

  • SignatureDoesNotMatch on the PUT: almost always a Content-Type mismatch between the signed command and the actual request, or a clock skew issue on your machine. Double-check both match exactly.
  • CORS error in the browser console: your bucket's CORS policy doesn't include the origin you're uploading from. Add http://localhost:3000 while developing.
  • 403 from R2 on the presign request itself: the API token isn't scoped to that bucket, or it expired. Regenerate it under R2 > Manage R2 API Tokens.
  • Worker returns 404 for a file that exists: check the binding name in wrangler.toml matches env.UPLOADS in your code, confirm you decoded the URL path, and that you're not double-encoding the key when you build the download URL client-side.

Next steps

For files over ~100MB, switch to R2's multipart upload API instead of a single presigned PUT. If you need access control on downloads (not just uploads), add a signed-token check inside the Worker before serving the object, since anyone who guesses a key can currently fetch it. And if you're migrating existing S3 data, Cloudflare's Super Slurper tool handles bulk migration without you writing a copy script.

Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading