Cloudflare R2 Setup

Used in this guide:
Next.js 15.3.4
Tailwind CSS 4
Cloudflare R2
AWS SDK 3.832.0

Introduction

This tutorial shows you how to connect Cloudflare R2 with your Next.js 15 app to handle secure file uploads using presigned URLs. You'll walk through the R2 setup, generate access tokens, configure environment variables, and implement a working upload page with live status feedback.

1. Get Your Cloudflare User ID

After creating your Cloudflare account, go to the CloudFlare Dashboard. Look at the URL, your User ID is the part right after cloudflare.com/ and before /home.


For example:
https://dash.cloudflare.com/your-user-id/home


2. Create an R2 Bucket

  • In the dashboard, use the side menu to go to R2 Object Storage > Overview
  • Click Create bucket button
  • Fill in Bucket name, Location: Automatic, Storage class: Standard, then click Create

Note: A credit card is required by CloudFlare for R2 access.




3. Create an API Token

  • While inside R2 Object Storage > Overview, click {} API button next to Create bucket button, then click Manage API Tokens
  • Click Create Account API Token

Fill in the form:

  • Name: e.g. myapp-r2-upload
  • Permissions: Object Read & Write
  • Bucket access: Select the bucket you just created in the previous step
  • TTL: Forever is fine for production
  • IP filtering: Leave default (no restriction)

After creation, you'll see:

  • Access Key ID
  • Secret Access Key
  • Endpoint URL

Make sure to copy and store them securely.




4. Add to .env.local

In your Next.js project root:


root/.env.local
R2_ACCOUNT_ID=your_user_id
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET=your_bucket_name
R2_REGION=auto
R2_ENDPOINT=your_r2_endpoint


5. Enable Public Dev URL

  • Go to your bucket's Setting > Public Development URL
  • click Enable
  • Copy the generated URL and add it to .env.local: R2_DEV_URL=https://<your-bucket-name>.r2.dev



6. Set CORS Policy

  • Got ot Settings > CORS Policy in your bucket
  • CLick + Add and paste the following code:
[
  {
    "AllowedOrigins": ["http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT", "DELETE"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Note: Update AllowedOrigins with your production domain later.




7. Install AWS SDK

Run this in your Next.js project:


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


8. Create the R2 Client

root/lib/r2.ts
import { S3Client } from "@aws-sdk/client-s3";
 
export const r2 = new S3Client({
  region: process.env.R2_REGION,
  endpoint: process.env.R2_ENDPOINT,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});


9. Create Upload API Route

app/api/upload-url/route.ts
import { r2 } from "@/lib/r2";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextResponse } from "next/server";
 
export async function POST(req: Request) {
  const { fileType, fileName } = await req.json();
 
  const url = await getSignedUrl(
    r2,
    new PutObjectCommand({
      Bucket: process.env.R2_BUCKET!,
      Key: fileName,
      ContentType: fileType,
    }),
    { expiresIn: 60 }
  );
 
  const publickUrl = `${process.env.R2_DEV_URL}/${fileName}`;
 
  return NextResponse.json({ url, publickUrl });
}


10. Create Upload Page

app/page.tsx
'use client'
import { useState } from "react";
 
export default function Home() {
  const [status, setStatus] = useState("");
  const [file, setFile] = useState<File | null>(null);
 
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFile(e.target.files?.[0] || null);
    setStatus("");
  };
 
  const uploadImage = async () => {
    if (!file) return;
 
    setStatus("Requesting upload URL...");
    const fileName = `test-image-${Date.now()}-${file.name}`;
    const fileType = file.type;
 
    const res = await fetch("/api/upload-url", {
      method: "POST",
      body: JSON.stringify({ fileName, fileType }),
      headers: { "Content-Type": "application/json" },
    });
 
    const { url } = await res.json();
 
    setStatus("Uploading...");
    const uploadRes = await fetch(url, {
      method: "PUT",
      headers: { "Content-Type": fileType },
      body: file,
    });
 
    setStatus(uploadRes.ok ? `Uploaded: ${fileName}` : "Upload failed");
  };
 
  return (
    <div className="p-4">
      <h1 className="text-lg font-semibold">Upload Image to R2</h1>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
        className="bg-sky-700 text-white p-2 px-4 mr-2 rounded-xl"
      />
      <button
        disabled={!file}
        onClick={uploadImage}
        className="px-4 py-2 bg-orange-500 text-white rounded disabled:opacity-50"
      >
        Upload Image
      </button>
      <p>{status}</p>
    </div>
  );
}

Try upload an image and navigate to your R2 bucket. You should see the uploaded image.

JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved