Skip to content

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.

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:

Terminal window
pnpm add -D https://pkg.alchemy.run/@alchemy.run/frontend-frameworks/pr:2930:a10573e

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 site as a module-level const (rather than inline in the Stack) and derive the typed shape of its bindings from it:

alchemy.run.ts
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.

Yield the class from your Stack and return its URL:

alchemy.run.ts
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.

The resource returns a plain Worker, so env accepts the full binding vocabulary — KV namespaces, R2 buckets, Durable Objects, secrets:

alchemy.run.ts
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.

Server code reads bindings from env in cloudflare:workers:

src/pages/index.astro
---
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:

src/env.d.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.

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,
});

Astro’s route cache runs on Workers Cache. Set the cache provider to cacheCloudflare() and give each route a rule in routeRules:

astro.config.ts
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():

src/pages/blog/[slug].astro
---
Astro.cache.set({ maxAge: 300, tags: ["blog"] });
---

Each response is tagged with its path. Purge a path or a tag with Astro.cache.invalidate():

src/pages/api/publish.ts
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ cache }) => {
await cache.invalidate({ tags: ["blog"] });
return new Response("purged");
};

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.

astro.config.ts
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.

Your astro.config.* is the primary place to configure Astro — integrations, Vite plugins, and every other option (serializable or not) work as usual:

astro.config.ts
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:

alchemy.run.ts
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.

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",
});