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
bun add -d @alchemy.run/frontend-frameworks

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/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.

Astro code reads bindings via Astro.locals.runtime.env (or import { env } from "cloudflare:workers"). Type the locals once by augmenting App.Locals with the inferred env type:

src/env.d.ts
import type { WebsiteEnv } from "../alchemy.run.ts";
declare global {
namespace App {
interface Locals {
runtime: { env: WebsiteEnv };
}
}
}
export {};

Every server-rendered page and endpoint now gets fully typed bindings:

src/pages/index.astro
---
const cached = await Astro.locals.runtime.env.CACHE.get("greeting");
---
<h1>{cached ?? "hello"}</h1>

Astro.locals.runtime.env.CACHE is typed as a KV namespace and .API_KEY as 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,
});

With astro: { output: "static" } every page is prerendered at build time and the deploy is assets-only — no server bundle is uploaded, and Cloudflare’s asset layer answers every request:

export const Website = Cloudflare.Website.Astro("Website", {
astro: { output: "static" },
assets: { notFoundHandling: "404-page" },
});

notFoundHandling: "404-page" serves the built 404.html for unmatched routes. Session provisioning is skipped for declared-static sites, since no Worker code runs at request time.

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

Two options are managed for you regardless of the config file:

  • adapter — Alchemy injects its wrangler-free Cloudflare adapter. Declaring an adapter in astro.config.* fails the build with an actionable error.
  • output — Alchemy defaults it to "server" (superseding a file-level output), so pages render on demand in the Worker. Opt into a fully prerendered site with astro: { output: "static" } on the resource.