Cloudflare R2 Setup
Used in this guide: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.
https://dash.cloudflare.com/your-user-id/home2. 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:
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_endpoint5. 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-presigner8. Create the R2 Client
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
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
'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.