Skip to content
Astro as integrations 4 min read

Cloudflare Adapter

Astro renders to static HTML by default, but when you need on-request rendering — auth, personalization, API routes, form handling — you need an adapter that maps Astro’s server output onto your host’s runtime. The @astrojs/cloudflare adapter targets Cloudflare’s edge, compiling your SSR routes into a Cloudflare Worker that runs in V8 isolates within milliseconds of your users worldwide. It gives you direct access to Cloudflare’s bindings — KV, D1, R2, Durable Objects — while preserving Astro’s zero-JS-by-default islands so you only ship dynamic behavior where you opt in.

Installing the adapter

The fastest path is astro add, which installs the package, updates astro.config.mjs, and sets the output mode for you.

npx astro add cloudflare

To do it manually, install the package and register the adapter yourself:

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

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

The Cloudflare runtime is the Workers runtime (workerd), not Node.js. It exposes Web-standard APIs — fetch, Request, Response, crypto.subtle, streams — but not Node built-ins like fs. Set platformProxy.enabled (shown below) so astro dev emulates those bindings locally.

Output modes

Astro distinguishes how each page is rendered. Pick the mode that matches how much of your site is dynamic.

output valueBehaviorUse when
staticEverything prerendered to HTML at build timeMostly content; no per-request logic
serverOn-demand rendering by default; opt out per pageDashboards, auth, APIs

With output: 'server' you can still prerender individual pages, keeping them as static assets served straight from Cloudflare’s CDN:

---
// src/pages/about.astro
export const prerender = true;
---
<html>
  <body><h1>About us</h1></body>
</html>

Under static output you opt a single route into on-demand rendering with export const prerender = false.

Accessing the runtime and bindings

Cloudflare injects environment variables, secrets, and resource bindings (KV, D1, R2) through the runtime context rather than process.env. The adapter surfaces them on Astro.locals.runtime. Configure local emulation so the same code works in astro dev:

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

export default defineConfig({
  output: 'server',
  adapter: cloudflare({
    platformProxy: { enabled: true },
  }),
});

Declare your bindings in wrangler.toml so they exist both locally and in production:

# wrangler.toml
name = "astro-app"
compatibility_date = "2025-05-01"
compatibility_flags = ["nodejs_compat"]

[[kv_namespaces]]
binding = "SESSIONS"
id = "f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6"

Then read a binding inside a server endpoint:

// src/pages/api/visits.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ locals }) => {
  const { SESSIONS } = locals.runtime.env;
  const count = Number((await SESSIONS.get('visits')) ?? 0) + 1;
  await SESSIONS.put('visits', String(count));

  return new Response(JSON.stringify({ visits: count }), {
    headers: { 'Content-Type': 'application/json' },
  });
};

Output:

$ curl "https://astro-app.pages.dev/api/visits"
{"visits":42}

For type safety, generate types from your config with Wrangler and reference them so locals.runtime.env is fully typed:

npx wrangler types

Per-route configuration

Because every dynamic route runs on the same Worker, you control delivery with prerender per page rather than choosing a runtime per route. Use route-level prerendering to push as much as possible to the static CDN tier:

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';

export const prerender = true;

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

Gotcha: code importing Node core modules will fail unless you set compatibility_flags = ["nodejs_compat"] and a recent compatibility_date. Even then, prefer Web-standard APIs and Cloudflare bindings — they are faster to start and fully supported on the edge.

Deploying

The adapter builds output for both Cloudflare Pages and standalone Workers. With Pages, connect the Git repository in the dashboard (it auto-detects Astro), or deploy from the CLI with Wrangler:

npm run build
npx wrangler pages deploy ./dist

Output:

✨ Compiled Worker successfully
🌎 Uploading... (12/12)
✨ Deployment complete!
   https://astro-app.pages.dev

Store secrets with npx wrangler pages secret put MY_SECRET rather than committing them, and they appear on locals.runtime.env alongside your bindings.

Best practices

  • Enable platformProxy: { enabled: true } so astro dev emulates KV, D1, R2, and secrets locally instead of failing on missing bindings.
  • Read environment values from Astro.locals.runtime.env, never process.env — the Workers runtime does not populate process.env by default.
  • Prerender every route you can with export const prerender = true so only truly dynamic pages run the Worker.
  • Declare bindings in wrangler.toml and run wrangler types to keep locals.runtime.env strongly typed.
  • Stick to Web-standard APIs and Cloudflare bindings; reach for nodejs_compat only when a dependency genuinely needs a Node built-in.
  • Manage secrets with wrangler pages secret put and reference them through the runtime, keeping them out of version control.
Last updated June 14, 2026
Was this helpful?