How to Set Up Cloudflare R2 with Next.js for File Uploads

Hiral Patel

Cloudflare R2 is an S3-compatible object storage service that works well for apps that need to upload and serve images, videos, PDFs, and other static files. In a Next.js project, you can use R2 for blog media, user avatars, project attachments, product images, exports, and any other file-based feature.

In this guide, we will set up Cloudflare R2 with a Next.js App Router project using the AWS SDK. R2 supports the S3 API through the endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com, and Cloudflare’s official examples use region: "auto" when creating the S3 client.

What We Are Building

We will create a basic upload flow where:

  1. A user uploads a file from the frontend.

  2. A Next.js API route receives the file.

  3. The API route uploads the file to Cloudflare R2.

  4. The app stores or returns the public file URL.

This setup is useful for blog platforms, portfolio projects, dashboards, CMS panels, and apps where users upload images or documents.

Step 1: Create a Cloudflare R2 Bucket

Go to your Cloudflare dashboard and open Storage & databases > R2. Create a new bucket and give it a clean name like:

my-nextjs-uploads

Cloudflare’s R2 setup flow asks you to create a bucket, choose a location, and then generate API credentials for S3-compatible access. For app usage, Cloudflare recommends creating an API token with Object Read & Write permissions and applying it only to the bucket you need.

Step 2: Generate R2 API Credentials

Inside the R2 dashboard:

  1. Go to R2 > Overview.

  2. Click Manage API Tokens.

  3. Create an API token.

  4. Give it Object Read & Write access.

  5. Restrict it to your specific bucket.

  6. Copy the Access Key ID and Secret Access Key.

Keep these credentials private. Never expose them in frontend code.

You will also need your Cloudflare account ID. Your R2 S3 endpoint follows this format:

https://<ACCOUNT_ID>.r2.cloudflarestorage.com

Step 3: Install the AWS SDK

R2 is S3-compatible, so we can use the AWS SDK for JavaScript.

npm install @aws-sdk/client-s3

If you also want presigned upload URLs later, install this package too:

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

Cloudflare’s official JavaScript examples use @aws-sdk/client-s3 for R2 operations like upload, download, and list objects.

Step 4: Add Environment Variables

Create or update your .env.local file:

R2_ACCOUNT_ID=your_cloudflare_account_id
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
R2_BUCKET_NAME=my-nextjs-uploads
R2_PUBLIC_URL=https://media.yourdomain.com

For local testing, you can use the R2 public development URL, but Cloudflare notes that the r2.dev public URL is intended for development and is rate-limited. For production, use a custom domain connected to your bucket.

Step 5: Create an R2 Client in Next.js

Create a file:

src/lib/r2.ts

Add this code:

import { S3Client } from "@aws-sdk/client-s3";

export const r2 = 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!,
  },
});

This creates a reusable R2 client for your API routes.

Step 6: Create a File Upload API Route

Next.js App Router uses route.ts files for API-style handlers. Route Handlers support HTTP methods like GET, POST, PUT, PATCH, and DELETE.

Create this file:

src/app/api/upload/route.ts

Add the upload logic:

import { PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import crypto from "crypto";
import { r2 } from "@/lib/r2";

export const runtime = "nodejs";

const allowedTypes = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif",
  "video/mp4",
  "application/pdf",
];

const maxFileSize = 10 * 1024 * 1024; // 10MB

export async function POST(req: Request) {
  try {
    const formData = await req.formData();
    const file = formData.get("file");

    if (!(file instanceof File)) {
      return NextResponse.json(
        { error: "File is required" },
        { status: 400 }
      );
    }

    if (!allowedTypes.includes(file.type)) {
      return NextResponse.json(
        { error: "Unsupported file type" },
        { status: 400 }
      );
    }

    if (file.size > maxFileSize) {
      return NextResponse.json(
        { error: "File size must be less than 10MB" },
        { status: 400 }
      );
    }

    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    const extension = file.name.split(".").pop();
    const fileKey = `uploads/${crypto.randomUUID()}.${extension}`;

    await r2.send(
      new PutObjectCommand({
        Bucket: process.env.R2_BUCKET_NAME!,
        Key: fileKey,
        Body: buffer,
        ContentType: file.type,
      })
    );

    const publicUrl = `${process.env.R2_PUBLIC_URL}/${fileKey}`;

    return NextResponse.json({
      success: true,
      key: fileKey,
      url: publicUrl,
    });
  } catch (error) {
    console.error("R2 upload error:", error);

    return NextResponse.json(
      { error: "Upload failed" },
      { status: 500 }
    );
  }
}

This API route validates the file, creates a unique filename, uploads it to R2, and returns the final public URL.

Step 7: Create a Frontend Upload Component

Create a simple upload component:

"use client";

import { useState } from "react";

export default function FileUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [uploadedUrl, setUploadedUrl] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleUpload() {
    if (!file) return;

    setLoading(true);

    const formData = new FormData();
    formData.append("file", file);

    const res = await fetch("/api/upload", {
      method: "POST",
      body: formData,
    });

    const data = await res.json();

    setLoading(false);

    if (!res.ok) {
      alert(data.error || "Upload failed");
      return;
    }

    setUploadedUrl(data.url);
  }

  return (
    <div>
      <input
        type="file"
        onChange={(event) => {
          const selectedFile = event.target.files?.[0];
          if (selectedFile) setFile(selectedFile);
        }}
      />

      <button onClick={handleUpload} disabled={!file || loading}>
        {loading ? "Uploading..." : "Upload"}
      </button>

      {uploadedUrl && (
        <div>
          <p>Uploaded successfully:</p>
          <a href={uploadedUrl} target="_blank" rel="noreferrer">
            {uploadedUrl}
          </a>
        </div>
      )}
    </div>
  );
}

Now you can upload files from your Next.js frontend to Cloudflare R2.

Step 8: Serve Images from R2 in Next.js

If you are using next/image, you need to allow your R2 public domain in next.config.ts.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "media.yourdomain.com",
        pathname: "/**",
      },
    ],
  },
};

export default nextConfig;

Next.js requires remote image sources to be configured using images.remotePatterns; otherwise, external images may be blocked by the Image component.

Then you can render R2 images like this:

import Image from "next/image";

export default function BlogImage() {
  return (
    <Image
      src="https://media.yourdomain.com/uploads/example.webp"
      alt="Uploaded blog image"
      width={1200}
      height={700}
    />
  );
}

Optional: Use Presigned URLs for Direct Browser Uploads

The upload route above sends the file through your Next.js server first. That is simple and works well for small files.

For larger files, a better approach is:

  1. The frontend asks your Next.js API for a temporary upload URL.

  2. The API route creates a presigned R2 URL.

  3. The browser uploads directly to R2 using that temporary URL.

Cloudflare supports presigned URLs for operations like GET, HEAD, PUT, and DELETE, but not POST form uploads. Cloudflare also notes that presigned URLs work with the R2 S3 API domain and not custom domains.

Create:

src/app/api/upload-url/route.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
import crypto from "crypto";
import { r2 } from "@/lib/r2";

export const runtime = "nodejs";

const allowedTypes = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "video/mp4",
  "application/pdf",
];

export async function POST(req: Request) {
  try {
    const { fileName, fileType } = await req.json();

    if (!fileName || !fileType) {
      return NextResponse.json(
        { error: "fileName and fileType are required" },
        { status: 400 }
      );
    }

    if (!allowedTypes.includes(fileType)) {
      return NextResponse.json(
        { error: "Unsupported file type" },
        { status: 400 }
      );
    }

    const extension = fileName.split(".").pop();
    const fileKey = `uploads/${crypto.randomUUID()}.${extension}`;

    const command = new PutObjectCommand({
      Bucket: process.env.R2_BUCKET_NAME!,
      Key: fileKey,
      ContentType: fileType,
    });

    const uploadUrl = await getSignedUrl(r2, command, {
      expiresIn: 60,
    });

    return NextResponse.json({
      uploadUrl,
      key: fileKey,
      publicUrl: `${process.env.R2_PUBLIC_URL}/${fileKey}`,
    });
  } catch (error) {
    console.error("Presigned URL error:", error);

    return NextResponse.json(
      { error: "Could not create upload URL" },
      { status: 500 }
    );
  }
}

Frontend example:

"use client";

import { useState } from "react";

export default function PresignedUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [uploadedUrl, setUploadedUrl] = useState("");

  async function uploadFile() {
    if (!file) return;

    const signedUrlRes = await fetch("/api/upload-url", {
      method: "POST",
      body: JSON.stringify({
        fileName: file.name,
        fileType: file.type,
      }),
      headers: {
        "Content-Type": "application/json",
      },
    });

    const signedUrlData = await signedUrlRes.json();

    if (!signedUrlRes.ok) {
      alert(signedUrlData.error || "Could not create upload URL");
      return;
    }

    const uploadRes = await fetch(signedUrlData.uploadUrl, {
      method: "PUT",
      body: file,
      headers: {
        "Content-Type": file.type,
      },
    });

    if (!uploadRes.ok) {
      alert("Upload failed");
      return;
    }

    setUploadedUrl(signedUrlData.publicUrl);
  }

  return (
    <div>
      <input
        type="file"
        onChange={(event) => {
          const selectedFile = event.target.files?.[0];
          if (selectedFile) setFile(selectedFile);
        }}
      />

      <button onClick={uploadFile} disabled={!file}>
        Upload Directly to R2
      </button>

      {uploadedUrl && (
        <a href={uploadedUrl} target="_blank" rel="noreferrer">
          View uploaded file
        </a>
      )}
    </div>
  );
}

Step 9: Configure CORS for Direct Uploads

If you upload directly from the browser using presigned URLs, you need to configure CORS on your R2 bucket.

Example CORS policy:

{
  "rules": [
    {
      "allowed": {
        "origins": [
          "http://localhost:3000",
          "https://yourdomain.com"
        ],
        "methods": ["GET", "PUT"],
        "headers": ["Content-Type"]
      },
      "maxAgeSeconds": 3600
    }
  ]
}

Cloudflare allows CORS policies to be added from the R2 bucket settings or through Wrangler. The rule must include the origin, method, and headers your browser request uses.

Recommended Folder Structure

A clean structure for your Next.js project can look like this:

src/
  app/
    api/
      upload/
        route.ts
      upload-url/
        route.ts
  components/
    FileUploader.tsx
    PresignedUploader.tsx
  lib/
    r2.ts

Security Best Practices

Do not expose R2 credentials in client components. Keep all access keys inside .env.local and only use them in server-side code.

Validate file types before uploading. Do not allow every file type unless your app really needs it.

Limit file size. This prevents users from uploading huge files through your API route.

Use random filenames. Do not directly trust the original filename because it may contain unsafe characters or duplicate existing files.

Use bucket-specific credentials. Your R2 token should only have access to the bucket your app needs.

Use presigned URLs for large uploads. This avoids sending large files through your Next.js server.

Use a custom domain for production public assets. Cloudflare says the r2.dev public development URL is intended for non-production usage.

Final Thoughts

Cloudflare R2 is a strong storage option for Next.js apps because it gives you an S3-compatible workflow without locking your code to AWS S3. For a simple blog or portfolio CMS, the server-upload method is enough. For larger production apps, presigned URLs are usually the better approach because the browser can upload files directly to R2.

With this setup, you can store blog images, videos, PDFs, markdown exports, user avatars, and project files in R2 while keeping only the file URLs and metadata in your database.

Comments

Sign in to join the discussion.

Be the first to comment.