/ astro-integrations / How to Integrate Cloudflare R2 with Astro: Complete Guide
astro-integrations 12 min read

How to Integrate Cloudflare R2 with Astro: Complete Guide

Step-by-step guide to integrating Cloudflare R2 with your Astro website.

How to Integrate Cloudflare R2 with Astro: Complete Guide

Cloudflare R2 is object storage that is S3-compatible but without egress fees. That last part matters a lot. With AWS S3, every byte your users download costs you money. R2 charges nothing for data transfer out. For image-heavy Astro blogs, portfolios, or media sites, this can save hundreds of dollars a month at scale.

R2 works through the S3 API, which means any S3-compatible library works with it. You store files in R2 buckets, serve them through Cloudflare's CDN, and your Astro site references them by URL. No vendor lock-in, no proprietary SDKs required.

There are two ways to talk to R2 from Astro, and this guide covers both. The first is the S3-compatible API through the AWS SDK, which works from any Node runtime regardless of where you host. The second is the native R2 binding, available only when you deploy on Cloudflare with the @astrojs/cloudflare adapter, where R2 is reached through the env object imported from cloudflare:workers with no API keys at all. Pick the S3 path if you host outside Cloudflare or want portability. Pick the binding path if you deploy to Cloudflare Workers and want the fastest, key-free access.

Prerequisites

  • Node.js v22.12.0 or higher. Astro 6 dropped support for Node 18 and 20, and odd-numbered Node releases such as v23 are not supported.
  • An Astro project (npm create astro@latest)
  • A Cloudflare account with R2 enabled. The Standard-storage free tier is 10 GB-month of storage, 1 million Class A operations (writes, lists) per month, and 10 million Class B operations (reads) per month. Egress (data transfer out) is always free. The free tier does not apply to Infrequent Access storage.
  • An R2 bucket created in the Cloudflare dashboard

Installation

Install the AWS S3 SDK (works with R2 since it is S3-compatible):

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

Both packages are published in lockstep, so keep their versions matched.

You Need an Adapter for the Upload Route

This is the part people miss, so handle it before writing any code. As of Astro 5 the output: 'hybrid' mode no longer exists. Astro is static by default and prerenders every page and endpoint to HTML at build time. An API route that accepts a POST cannot be prerendered, so two things are required for the /api/upload.ts endpoint later in this guide to work:

  1. Add a server adapter. For a generic Node host, run npx astro add node. If you deploy on Cloudflare, run npx astro add cloudflare instead. An adapter is required for any on-demand route even when the rest of the site stays static.
  2. Mark the route as on-demand with export const prerender = false at the top of the endpoint file (shown in the example below). In the default static output you opt routes out of prerendering one at a time. If you prefer every route to render on demand, set output: 'server' in astro.config.mjs instead and the per-route flag is no longer needed.

For the Node adapter the config looks like this:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
});

If you are on Cloudflare, use @astrojs/cloudflare instead, which also unlocks the native R2 binding shown near the end of this guide:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  adapter: cloudflare(),
});

Configuration

Create an R2 API token in the Cloudflare dashboard under R2 > Manage R2 API Tokens. You need:

  • Access Key ID
  • Secret Access Key
  • Account ID (found in your Cloudflare dashboard sidebar)
  • Bucket name

Add them to your .env:

R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_ACCOUNT_ID=your_account_id
R2_BUCKET_NAME=your_bucket_name
R2_PUBLIC_URL=https://pub-xxxx.r2.dev

Create the R2 client:

// src/lib/r2.ts
import { S3Client } from "@aws-sdk/client-s3";

export const r2Client = new S3Client({
  // "auto" is required by the SDK but ignored by R2; R2 has no regions.
  region: "auto",
  // Endpoint format matches Cloudflare's official AWS SDK JS v3 example.
  endpoint: `https://${import.meta.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: import.meta.env.R2_ACCESS_KEY_ID,
    secretAccessKey: import.meta.env.R2_SECRET_ACCESS_KEY,
  },
});

export const BUCKET_NAME = import.meta.env.R2_BUCKET_NAME;
export const PUBLIC_URL = import.meta.env.R2_PUBLIC_URL;

Enabling Public Access

By default, R2 buckets are private. To serve files publicly:

  1. Go to your R2 bucket in the Cloudflare dashboard
  2. Open Settings
  3. Either enable the Public Development URL (the managed r2.dev subdomain) for quick testing, or connect a custom domain for production

Use the custom domain approach in production. Cloudflare's own docs state that the r2.dev subdomain is rate limited and intended for development and non-production traffic only, and that caching, WAF, bot management, and Cloudflare Access are unavailable on it. A custom domain unlocks all of those. Under your bucket settings, open Custom Domains, add a domain like media.yourdomain.com that lives in the same Cloudflare account, and confirm the DNS record. Cloudflare handles the SSL and CDN caching automatically.

If you connect a custom domain and put WAF or Cloudflare Access in front of it, also disable the r2.dev public URL so the bucket cannot be reached through the unprotected path.

Uploading Files

Create a utility function for uploads:

// src/lib/r2.ts (add to existing file)
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";

export async function uploadToR2(
  file: Buffer,
  key: string,
  contentType: string
): Promise<string> {
  await r2Client.send(
    new PutObjectCommand({
      Bucket: BUCKET_NAME,
      Key: key,
      Body: file,
      ContentType: contentType,
    })
  );
  return `${PUBLIC_URL}/${key}`;
}

export async function deleteFromR2(key: string): Promise<void> {
  await r2Client.send(
    new DeleteObjectCommand({
      Bucket: BUCKET_NAME,
      Key: key,
    })
  );
}

API Route for Image Uploads

Create an API endpoint for handling uploads. The export const prerender = false line is what tells Astro to render this route on demand instead of prerendering it at build time. Without it, and without an adapter from the Installation section, this POST handler will not run.

// src/pages/api/upload.ts
import type { APIRoute } from "astro";
import { uploadToR2 } from "../../lib/r2";
import { randomUUID } from "crypto";

// Required in the default static output so this POST route renders on demand.
export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  try {
    const formData = await request.formData();
    const file = formData.get("file") as File;

    if (!file) {
      return new Response(JSON.stringify({ error: "No file provided" }), {
        status: 400,
      });
    }

    const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/avif"];
    if (!allowedTypes.includes(file.type)) {
      return new Response(JSON.stringify({ error: "Invalid file type" }), {
        status: 400,
      });
    }

    const ext = file.name.split(".").pop();
    const key = `uploads/${randomUUID()}.${ext}`;
    const buffer = Buffer.from(await file.arrayBuffer());

    const url = await uploadToR2(buffer, key, file.type);

    return new Response(JSON.stringify({ url, key }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Upload error:", error);
    return new Response(JSON.stringify({ error: "Upload failed" }), {
      status: 500,
    });
  }
};

Using R2 Images in Blog Posts

Reference your R2-hosted images in MDX frontmatter:

---
heroImage: "https://media.yourdomain.com/uploads/hero-image.webp"
heroImageAlt: "A description of the image"
---

Or inline in your MDX content:

![Alt text](https://media.yourdomain.com/uploads/diagram.webp)

Presigned URLs for Direct Uploads

For larger files, let the browser upload directly to R2 instead of going through your server:

// src/lib/r2.ts (add to existing file)
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export async function getPresignedUploadUrl(
  key: string,
  contentType: string
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });
  return getSignedUrl(r2Client, command, { expiresIn: 3600 });
}

The frontend requests a presigned URL from your API, then uploads directly to R2. This keeps large files off your server. The route that mints the presigned URL is also a POST endpoint, so it needs the same export const prerender = false and adapter setup as the upload route.

Native R2 Binding on Cloudflare

If you deploy with the @astrojs/cloudflare adapter, you can skip the AWS SDK and API keys entirely and reach R2 through a binding. As of the v13 adapter that ships with Astro 6, the old Astro.locals.runtime.env access path is removed; you now import env directly from cloudflare:workers and read the binding off it, which works the same in dev and in production.

Declare the bucket binding in your Wrangler config:

// wrangler.jsonc
{
  "name": "my-astro-site",
  "compatibility_date": "2025-05-29",
  "r2_buckets": [
    { "binding": "MEDIA_BUCKET", "bucket_name": "your-bucket-name" }
  ]
}

Then use the binding from an on-demand endpoint:

// src/pages/api/upload-binding.ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const formData = await request.formData();
  const file = formData.get("file") as File;

  const key = `uploads/${crypto.randomUUID()}.${file.name.split(".").pop()}`;
  await env.MEDIA_BUCKET.put(key, file.stream(), {
    httpMetadata: { contentType: file.type },
  });

  return new Response(JSON.stringify({ key }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

In Astro 6, astro dev and astro preview run your site on the real Workers runtime (workerd) through the Cloudflare Vite plugin, so the binding behaves locally the way it does in production. Use the S3 path earlier in this guide if you need portability across hosts; use the binding path if you are committed to Cloudflare and want key-free, lowest-latency access.

Production Tips

  1. Organize with prefixes. Use folder-like prefixes in your keys: blog-images/2025/01/hero.webp. R2 does not have real folders, but prefixes keep things organized and make cleanup easier.

  2. Set cache headers. When uploading, add CacheControl: "public, max-age=31536000, immutable" for static assets. Cloudflare's CDN respects these headers and caches accordingly.

  3. Use image transforms. Cloudflare Images can resize and optimize images on the fly when served through a custom domain. Add query parameters like ?width=800&format=webp to transform at the edge.

  4. Monitor usage. The Cloudflare dashboard shows R2 storage and operation counts. The free tier is generous, but Class A operations (writes and lists) cap at 1 million per month and Class B operations (reads) at 10 million per month on Standard storage, so keep an eye on both.

  5. Back up your bucket. R2 supports lifecycle rules and cross-bucket replication. For critical media assets, enable versioning so accidental deletions can be recovered.

Alternatives to Consider

  • AWS S3 + CloudFront if you need more advanced features like event notifications and Lambda triggers, and egress costs are not a concern.
  • Bunny CDN Storage if you want even simpler pricing with a pull zone CDN included.
  • Uploadthing if you want a managed upload service with built-in file validation and do not need raw S3 access.

Common Errors and Fixes

POST returns a 404 or the route serves static HTML. In Astro's default static output, an endpoint without export const prerender = false gets prerendered at build time and the POST handler never runs. Add the flag to the endpoint and confirm an adapter is installed. Astro also dropped output: 'hybrid' in version 5, so config that still sets hybrid will fail to build; remove it and rely on static plus per-route prerender = false, or switch to output: 'server'.

Build fails on a fresh install with an engine or syntax error. Astro 6 requires Node.js v22.12.0 or higher and does not support Node 18 or 20. Check node -v and upgrade if you are below that line. Odd-numbered Node releases such as v23 are also unsupported.

SignatureDoesNotMatch or 403 from the S3 client. This usually means the endpoint or region is wrong. R2 has no regions, so region must be the literal string "auto", and the endpoint must be https://<ACCOUNT_ID>.r2.cloudflarestorage.com with your account ID, not a bucket-scoped or AWS-style host.

Uploaded files return 401 or 403 when fetched by URL. R2 buckets are private by default. Enable the Public Development URL or connect a custom domain under the bucket's Settings. Remember the r2.dev URL is rate limited and meant for development, so use a custom domain for anything user-facing.

The binding env is undefined. The native R2 binding only exists when the @astrojs/cloudflare adapter is active and the route renders on demand. With the v13 adapter on Astro 6 the old Astro.locals.runtime.env path is gone; import env from cloudflare:workers and read the binding off it. Confirm the bucket is declared under r2_buckets in your Wrangler config with a matching binding name.

The S3 API does not work under wrangler dev. Cloudflare notes the S3-compatible API cannot be used while developing locally via wrangler dev. If you are on Cloudflare, use the native binding for local development, which Astro 6 runs on the real Workers runtime through astro dev.

Official Docs and Examples

Wrapping Up

Cloudflare R2 solves the biggest pain point of object storage, which is unpredictable egress costs. For Astro sites that serve a lot of media, the zero egress pricing combined with Cloudflare's global CDN makes R2 the most cost-effective option. The S3-compatible API means you can switch to or from R2 without rewriting your code, and if you deploy on Cloudflare the native binding drops the API keys entirely. Set up a bucket, point your custom domain at it, and your Astro site has a fast, cheap media backend.

Sources

Checked on 2026-05-29.