Astro
Cloudflare.Website.Astro deploys an Astro
project as a Cloudflare Worker. It runs Astro’s programmatic build
with a wrangler-free Cloudflare adapter: server-rendered pages
execute in the Worker, prerendered pages and client assets deploy as
static assets. Your astro.config.* loads natively — there is no
adapter to install into it and no Wrangler file to write.
Install
Section titled “Install”The build integration is not bundled with alchemy. Install
@alchemy.run/frontend-frameworks; the resource loads its /astro
export from your project at deploy time. It is only used at build time,
so a dev dependency is enough:
pnpm add -D https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2930:a10573ebun add -d https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2930:a10573enpm install -D https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2930:a10573eyarn add -D https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2930:a10573eConfigure Astro
Section titled “Configure Astro”Your astro.config.* file loads natively — integrations, Vite
plugins, and any other non-serializable options work exactly as they
do outside Alchemy. Alchemy merges a programmatic config over it:
the Cloudflare adapter is injected for you (don’t declare an
adapter in your config — that fails the build with an actionable
error), and options passed via the astro prop override the file’s —
see Astro configuration below for the
details.
Declare the Website
Section titled “Declare the Website”Declare the site as a module-level const (rather than inline in the Stack) and derive the typed shape of its bindings from it:
import * as Cloudflare from "@alchemy.run/cloudflare";
export const Website = Cloudflare.Website.Astro("Website");
export type WebsiteEnv = Cloudflare.InferEnv<typeof Website>;WebsiteEnv is the typed shape of the Worker’s bindings, derived
from the class — you’ll import it into your Astro code in
Read bindings in server code.
Add it to the Stack
Section titled “Add it to the Stack”Yield the class from your Stack and return its URL:
import * as Alchemy from "alchemy";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "MyAstroSite", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const site = yield* Website; return { url: site.url }; }),);Pages are server-rendered by default. Pages that
export const prerender = true are served as static assets. Astro’s
server runtime is built against Node APIs, so the nodejs_compat
compatibility flag is added automatically — you don’t need to pass
it.
During alchemy dev, Astro’s dev server runs with SSR executing in
workerd, so server code sees the same runtime and the same real
bindings it will have in production.
See examples/cloudflare-website-astro for the checked-in example.
Add bindings
Section titled “Add bindings”The resource returns a plain Worker, so env accepts the full
binding vocabulary — KV namespaces, R2 buckets, Durable Objects,
secrets:
import * as Config from "effect/Config";
export const Cache = Cloudflare.KV.Namespace("Cache");
export const Website = Cloudflare.Website.Astro("Website", { env: { CACHE: Cache, API_KEY: Config.Redacted("API_KEY"), },});Cache is a description, not a deploy — Alchemy provisions the real
namespace because the Website binds it. Config.Redacted reads
API_KEY from your environment at deploy time and binds it as a
Worker secret — see Secrets & env.
Read bindings in server code
Section titled “Read bindings in server code”Server code reads bindings from env in cloudflare:workers:
---import { env } from "cloudflare:workers";
const cached = await env.CACHE.get("greeting");---
<h1>{cached ?? "hello"}</h1>Astro.locals.runtime.env was removed in Astro 6 and throws if you
read it.
Type env once with the WebsiteEnv type from alchemy.run.ts:
import type { WebsiteEnv } from "../alchemy.run.ts";
declare module "cloudflare:workers" { namespace Cloudflare { interface Env extends WebsiteEnv {} }}env.CACHE is now a KV namespace and env.API_KEY a string.
Renaming a binding in alchemy.run.ts is a type error in your
pages.
Sessions
Section titled “Sessions”Astro’s session API is backed by a KV namespace. One is provisioned
and bound as SESSION automatically, so Astro.session works with
zero configuration.
Bind your own namespace under that name to use it instead:
export const Sessions = Cloudflare.KV.Namespace("Sessions");
export const Website = Cloudflare.Website.Astro("Website", { env: { SESSION: Sessions, },});Or opt out of session provisioning entirely:
export const Website = Cloudflare.Website.Astro("Website", { sessionKVBindingName: false,});Route caching
Section titled “Route caching”Astro’s route cache runs on Workers Cache. Set the cache provider to
cacheCloudflare() and give each route a rule in routeRules:
import { defineConfig } from "astro/config";import { cacheCloudflare } from "@alchemy.run/frontend-frameworks/astro/cache";
export default defineConfig({ cache: { provider: cacheCloudflare() }, routeRules: { "/insights": { maxAge: 600, swr: 86400 }, },});Alchemy sees the provider and turns on Workers Cache for the Worker.
The edge then serves /insights for 10 minutes without running the
Worker. A route with no rule is never cached.
Import cacheCloudflare from @alchemy.run/frontend-frameworks, not
@astrojs/cloudflare/cache. The upstream package needs Wrangler.
Set a rule from a page with Astro.cache.set():
---Astro.cache.set({ maxAge: 300, tags: ["blog"] });---Each response is tagged with its path. Purge a path or a tag with
Astro.cache.invalidate():
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ cache }) => { await cache.invalidate({ tags: ["blog"] }); return new Response("purged");};Fully static sites
Section titled “Fully static sites”Set output: "static" in astro.config.* and every page is
prerendered at build time. The deploy is assets-only: no server
bundle is uploaded, and Cloudflare’s asset layer answers every
request.
import { defineConfig } from "astro/config";
export default defineConfig({ output: "static" });Serve the built 404.html for unmatched routes:
export const Website = Cloudflare.Website.Astro("Website", { assets: { notFoundHandling: "404-page" },});No Worker code runs at request time, so a static site gets no session namespace.
Astro configuration
Section titled “Astro configuration”Your astro.config.* is the primary place to configure Astro —
integrations, Vite plugins, and every other option (serializable or
not) work as usual:
import { defineConfig } from "astro/config";import react from "@astrojs/react";import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ site: "https://blog.example.com", integrations: [react()], vite: { plugins: [tailwindcss()], },});A config file can’t consume Alchemy Outputs or vary by stage. For
those values the astro prop is a deploy-time override bag — a
serializable subset (site, base, output, srcDir, publicDir,
outDir, trailingSlash) merged over the config file, so a value
set here wins:
import { Stack } from "alchemy/Stack";
export const Website = Cloudflare.Website.Astro( "Website", Stack.useSync(({ stage }) => ({ astro: { site: stage === "prod" ? "https://blog.example.com" : `https://${stage}.example.com`, }, })),);Use config to load an alternate config file (relative to
rootDir) instead of astro’s own astro.config.* discovery:
export const Website = Cloudflare.Website.Astro("Website", { config: "astro.deploy.config.ts",});Alchemy injects its wrangler-free Cloudflare adapter. Declaring an
adapter in astro.config.* fails the build with an error that
tells you to remove it.
output comes from the astro prop, then the config file, then
defaults to "server". Astro’s own default is "static", which
would prerender every page at build time, where the Worker’s
bindings don’t exist.
Prerendering runtime
Section titled “Prerendering runtime”Prerendered pages render in workerd at build time, the same runtime as the deployed Worker. Pages that need Node APIs while prerendering can use Astro’s Node prerenderer instead:
export const Website = Cloudflare.Website.Astro("Website", { prerenderEnvironment: "node",});