How to Set Up R2 Object Storage for File Uploads in a Next.js Cloudflare Project
To set up R2 object storage in a Next.js Cloudflare project, create an R2 bucket, bind it in wrangler.jsonc, configure the CLOUDFLARE_R2_URL environment variable, and use the uploadToR2 helper from src/lib/r2.ts to handle file uploads in server actions.
The ifindev/fullstack-next-cloudflare repository demonstrates a production-ready pattern for integrating Cloudflare R2 with Next.js using the @opennextjs/cloudflare adapter. This approach allows you to upload files directly from Next.js server actions without maintaining separate API endpoints, leveraging Cloudflare's edge runtime for low-latency object storage operations.
Create and Configure Your R2 Bucket
Start by provisioning the storage resource in the Cloudflare dashboard.
- Navigate to R2 → Buckets and click Create bucket.
- Assign a unique name (e.g.,
next-cf-app-bucket). - Note the public URL displayed as
https://<bucket>.r2.cloudflarestorage.com. You will store this value as theCLOUDFLARE_R2_URLenvironment variable to construct shareable download links after uploads complete.
Bind the R2 Bucket to Your Worker
Next, declare the bucket binding in your Worker configuration so the runtime can inject the R2 client into your application.
In wrangler.jsonc, locate the r2_buckets array and verify the binding matches your bucket name:
"r2_buckets": [
{
"bucket_name": "next-cf-app-bucket",
"binding": "next_cf_app_bucket",
"preview_bucket_name": "next-cf-app-dev-bucket"
}
]
- The
bucket_namemust match the exact name of your R2 bucket in the Cloudflare dashboard. - The
bindingname (next_cf_app_bucket) is the identifier you will use to access the bucket in code viaenv.next_cf_app_bucket. - The optional
preview_bucket_namepoints to a separate bucket for local development.
If you change the binding name here, you must update all references in src/lib/r2.ts and any server actions that invoke the bucket.
Configure Environment Variables
The worker requires the public R2 endpoint to generate accessible URLs for uploaded files. Set this in both local and production environments.
Add the following to .dev.vars.example (and your actual .dev.vars for local development):
CLOUDFLARE_R2_URL=next-cf-app-bucket.r2.cloudflarestorage.com
For production, add the same key-value pair in the Cloudflare dashboard under Workers & Pages → Environment Variables. Store sensitive credentials like API keys as Secrets rather than plain text variables.
Implement File Uploads with the R2 Helper
The repository provides a centralized utility in src/lib/r2.ts that wraps the R2 binding with key generation, metadata tagging, and URL construction.
The uploadToR2 function accepts a File object and an optional folder path, then executes the upload:
export async function uploadToR2(file: File, folder = "uploads"): Promise<UploadResult> {
const { env } = await getCloudflareContext();
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 15);
const extension = file.name.split(".").pop() ?? "bin";
const key = `${folder}/${timestamp}_${randomId}.${extension}`;
const arrayBuffer = await file.arrayBuffer();
const result = await env.next_cf_app_bucket.put(key, arrayBuffer, {
httpMetadata: {
contentType: file.type,
cacheControl: "public, max-age=31536000",
},
customMetadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
size: file.size.toString(),
},
});
const publicUrl = `https://${(env as any).CLOUDFLARE_R2_URL}/${key}`;
return result ? { success: true, url: publicUrl, key } : { success: false, error: "Upload failed" };
}
Key implementation details:
- Key generation: Combines a timestamp and random string to prevent collisions and maintain sortability.
- getCloudflareContext(): Provided by @opennextjs/cloudflare, this function returns the runtime
envobject containing your R2 binding. - Metadata: Stores the original filename, upload timestamp, and file size as custom metadata for later retrieval.
- Public URL construction: Concatenates the
CLOUDFLARE_R2_URLenvironment variable with the object key to create a directly accessible link.
Use R2 Uploads in Server Actions
Integrate the helper into Next.js server actions to handle file uploads from forms. The repository demonstrates this pattern in two Todo-related actions:
- Creating Todos:
src/modules/todos/actions/create-todo.action.tsextracts an optional image from FormData and uploads it during record creation. - Updating Todos:
src/modules/todos/actions/update-todo.action.tshandles replacing existing attachments.
Both actions follow the same flow: extract the File from FormData, invoke uploadToR2, and persist the returned URL in the database.
Minimal Upload Server Action
Create a reusable server action that accepts FormData and returns the public URL:
"use server";
import { uploadToR2 } from "@/lib/r2";
export async function uploadImage(formData: FormData) {
const file = formData.get("image") as File | null;
if (!file) throw new Error("No file supplied");
const result = await uploadToR2(file, "my-uploads");
if (!result.success) throw new Error(`Upload failed: ${result.error}`);
// result.url contains the public R2 link
return result.url;
}
Client Component Integration
Submit files from the browser using a standard form POST:
"use client";
export default function ImageUploader() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const url = await fetch("/api/upload-image", {
method: "POST",
body: formData,
}).then((res) => res.text());
alert(`File uploaded! Public URL: ${url}`);
};
return (
<form onSubmit={handleSubmit}>
<input type="file" name="image" accept="image/*" required />
<button type="submit">Upload</button>
</form>
);
}
Behind the scenes, the /api/upload-image route invokes the uploadImage server action shown above.
Deploy Your Application
Deploy the application to make the R2 binding and environment variables available in the Cloudflare runtime.
- Development: Run
wrangler devto start a local server with live reloading. The CLI automatically binds your preview bucket and injects environment variables from.dev.vars. - Production: Run
wrangler deploy(orwrangler publishon older CLI versions) to push the Worker to Cloudflare's edge network. Ther2_bucketsbinding and environment variables configured in the dashboard are automatically attached to the deployment.
Once deployed, any server action calling uploadToR2 will execute against your live R2 bucket.
Summary
- Create an R2 bucket in the Cloudflare dashboard and copy its public URL for the
CLOUDFLARE_R2_URLvariable. - Configure the binding in
wrangler.jsoncunder ther2_bucketsarray, ensuring thebindingname matches the code referenceenv.next_cf_app_bucket. - Set environment variables locally in
.dev.varsand in the Cloudflare dashboard for production. - Use the helper function
uploadToR2fromsrc/lib/r2.tsto handle key generation, metadata, and public URL construction in server actions. - Deploy with Wrangler to inject bindings and variables into the edge runtime.
Frequently Asked Questions
What is the difference between the bucket name and the binding name in wrangler.jsonc?
The bucket_name is the actual identifier of your R2 bucket in the Cloudflare dashboard (e.g., next-cf-app-bucket), while the binding name (e.g., next_cf_app_bucket) is the JavaScript property name used to access the bucket in your code via env.next_cf_app_bucket. Changing the binding name requires updating all references in src/lib/r2.ts and related server actions.
Why do I need the CLOUDFLARE_R2_URL environment variable if the binding provides access?
The R2 binding provides programmatic access to read and write objects, but it does not expose the public HTTP endpoint of your bucket. The CLOUDFLARE_R2_URL variable (e.g., your-bucket.r2.cloudflarestorage.com) allows the uploadToR2 helper to construct shareable HTTPS URLs that clients can use to directly download uploaded files without proxying through your Worker.
Can I upload files larger than 100MB using this setup?
The uploadToR2 helper in src/lib/r2.ts currently loads the entire file into memory using file.arrayBuffer() before uploading. For files larger than a few hundred megabytes, you should implement multipart uploads using the R2 multipart API, or stream the upload directly to avoid memory constraints in the Cloudflare Workers runtime.
How do I access the R2 bucket in local development versus production?
During local development with wrangler dev, the CLI automatically binds the preview_bucket_name specified in wrangler.jsonc and injects variables from .dev.vars. In production, Cloudflare injects the production bucket_name and dashboard-configured environment variables. The code remains identical in both environments because getCloudflareContext() abstracts the runtime difference.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →