Astro on Cloudflare Workers
Astro is the best choice for content-heavy sites on Cloudflare. It ships zero JavaScript by default and lets you add interactive “islands” with React, Svelte, Vue, or any other UI framework. Adapter v13 uses the CF Vite Plugin for full workerd dev parity.
Create a New Project
npm create cloudflare@latest my-astro-site -- --framework=astro
Or add Cloudflare to an existing Astro project:
npx astro add cloudflare
Project Structure
my-astro-site/
├── src/
│ ├── pages/ # File-based routing
│ │ ├── index.astro # Static page
│ │ └── api/
│ │ └── hello.ts # API endpoint
│ ├── components/ # UI components
│ └── layouts/ # Page layouts
├── astro.config.mjs # Astro + adapter config
└── wrangler.jsonc # Cloudflare config
Astro Config
// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
adapter: cloudflare(),
});
For a fully static site (no SSR):
// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
adapter: cloudflare(),
output: "static",
});
Static Page
---
// src/pages/index.astro
const title = "My Cloudflare Site";
---
<html>
<head><title>{title}</title></head>
<body>
<h1>{title}</h1>
<p>This page ships zero JavaScript.</p>
</body>
</html>
Server Endpoint with Bindings
// src/pages/api/hello.ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";
export const GET: APIRoute = async ({ request }) => {
const value = await env.MY_KV.get("greeting");
return new Response(JSON.stringify({ message: value || "Hello" }), {
headers: { "content-type": "application/json" },
});
};
Gotcha: Astro adapter v13 removed
Astro.locals.runtime. Useimport { env } from "cloudflare:workers"instead. This is a breaking change from earlier versions.
SSR Page with Bindings
---
// src/pages/dashboard.astro
import { env } from "cloudflare:workers";
const visits = await env.MY_KV.get("visits");
---
<html>
<body>
<h1>Dashboard</h1>
<p>Total visits: {visits || 0}</p>
</body>
</html>
Interactive Island
---
// src/pages/interactive.astro
import Counter from "../components/Counter";
---
<html>
<body>
<h1>Mostly static page</h1>
<p>This text ships as HTML. The counter below is a React island.</p>
<Counter client:load initialCount={0} />
</body>
</html>
// src/components/Counter.tsx
import { useState } from "react";
export default function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
Dev and Deploy
# Development (runs in workerd via CF Vite Plugin in adapter v13)
npm run dev
# Build
npm run build
# Deploy
npx wrangler deploy
Adapter v13 Breaking Changes
If you’re upgrading from an earlier Astro Cloudflare adapter:
Astro.locals.runtimeis gone. Useimport { env } from "cloudflare:workers".- Pages deployment is removed. Only Workers Static Assets is supported.
- Dev server now runs in
workerd(via CF Vite Plugin). CJS dependencies may need pre-compilation. - The
platformProxyconfig option is removed (no longer needed since dev uses realworkerd).
Gotcha: CJS dependencies that worked before may fail in
workerd. If you hit errors, add them tovite.ssr.externalor switch to ESM versions.
Key Points
- Zero JS by default; add interactivity with
client:*directives on components - File-based routing in
src/pages/ import { env } from "cloudflare:workers"for bindings (adapter v13+)- Supports React, Svelte, Vue, Solid, Preact islands in the same project
- Adapter v13 uses CF Vite Plugin for real
workerddev experience - Best for: blogs, docs sites, marketing pages, content-driven apps with pockets of interactivity