TL;DR
Three full-stack frameworks that run well on Workers:
- Astro: SSG by default, zero client JS unless you opt into islands, Content Collections for blogs/docs.
- Remix (React Router 7): SSR by default, streaming, React ecosystem. Strong for form-heavy dynamic apps.
- SvelteKit: hybrid per-route, the smallest runtime, minimalist DX.
The key claim:
Your framework choice isn’t driven by Cloudflare support (all three work fine). It’s driven by rendering model and workload: content sites → Astro, dynamic apps → Remix/RR7, hybrid sites with a small runtime → SvelteKit. This blog uses Astro because 58 static posts + 8 API endpoints fit SSG + Workers Assets almost perfectly.
This post walks through real setups for each framework, the three rendering models (SSG/SSR/hybrid), how each framework exposes bindings, and the real-world gotchas.
Who this is for
- Developers picking a framework for a Workers project.
- Next.js users looking for Workers alternatives (Next.js Edge isn’t covered here).
- Anyone deciding SSG vs SSR per route.
Read first: Part 2 (runtime), Part 4 (Wrangler dev loop), Part 9 (router).
After this post you’ll:
- Set up all three frameworks from scratch on Workers.
- Distinguish SSG / SSR / hybrid and know when each is right.
- Access bindings (DB, KV, R2, Queue) from framework handlers.
- Know the real gotchas.
What this post isn’t about
- Next.js:
@cloudflare/next-on-pagesis complex enough to deserve its own post. The Workers Assets + Next combination is also newer and less mature. - Solid / Qwik / Nuxt: they have Workers adapters but are less common in this niche.
- Deep framework features (authentication, forms, suspense): each framework’s own docs cover those.
Framework comparison
3 rendering models
SSG (Static Site Generation)
HTML is rendered at build time. Files are deployed to Workers Assets. A request → the Worker serves the file via env.ASSETS.fetch().
npm run build
→ dist/
├── index.html
├── blog/
│ ├── hello/index.html
│ └── world/index.html
└── _worker.js (optional, if any dynamic routes)
Pros: fastest option, automatic CDN caching, zero CPU cost per request.
Cons: data is frozen at build time. Dynamic data needs client-side fetch or a rebuild.
SSR (Server-Side Rendering)
The Worker handler renders HTML on every request. The build produces _worker.js + client JS chunks.
npm run build
→ dist/
├── _worker.js (main handler)
├── _astro/ (client chunks, if any)
└── ...
Request → _worker.js → render HTML → streamed back to the client.
Pros: fresh data per request, personalisation, auth, dynamic rendering.
Cons: CPU cost per request. Cache stratification gets complex.
Hybrid
Mix per route. Astro / SvelteKit / Remix all support it.
/blog/[slug] → SSG (pre-rendered at build time)
/dashboard → SSR (rendered per request)
/api/* → pure Worker handlers
The framework routes smartly: a request for a static file is served by Workers Assets, a dynamic route hits the handler.
Pros: best of both worlds.
Cons: more config; you need a clear picture of each route.
Astro 5 on Workers
This blog runs Astro + Workers Assets. Setup took ~30 minutes from scratch.
Install
npm create astro@latest my-blog
cd my-blog
npm install @astrojs/cloudflare
astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://my-blog.com",
output: "static", // SSG by default
adapter: cloudflare(),
integrations: [mdx(), sitemap()],
});
output: "static" — SSG for the whole site. Switch to "server" for pure SSR, "hybrid" for a mix.
Content Collections
Astro’s most distinctive feature: content as typed data.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
A post in src/content/blog/*.md:
---
title: "Hello World"
pubDate: 2026-05-04
draft: false
---
Content of the post.
Astro validates frontmatter and gives type-safe access on the page:
---
// src/pages/blog/[...slug].astro
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog", p => !p.data.draft);
return posts.map(post => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
Accessing bindings
With Astro + the Cloudflare adapter, bindings come through locals.runtime.env in SSR:
---
// src/pages/api/subscribe.ts (Astro endpoint)
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, locals }) => {
const env = locals.runtime.env;
const { email } = await request.json();
await env.DB.prepare("INSERT INTO subscribers (email) VALUES (?)")
.bind(email)
.run();
return new Response(JSON.stringify({ ok: true }));
};
In SSG, there’s no locals.runtime.env (build time has no bindings). Use static data or build-time fetches.
wrangler.jsonc
{
"name": "my-blog",
"main": "./dist/_worker.js/index.js",
"compatibility_date": "2026-05-01",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
},
"d1_databases": [
{ "binding": "DB", "database_name": "my-db", "database_id": "..." }
]
}
not_found_handling: "404-page" uses dist/404.html with HTTP 404 (not SPA fallback). Critical for SEO.
Deploy
npm run build
npx wrangler deploy
Astro gotchas
- Island hydration: only components with a
client:*directive ship JS.<SomeComponent client:load />= load JS immediately,client:visible= lazy,client:idle= deferred. - Build-time Content Collections: frontmatter is validated at build time. A wrong frontmatter = build failure, not runtime error.
siteURL: must be set inastro.config.mjsso RSS + sitemap generate correctly.
Remix / React Router 7 on Workers
Remix merged with React Router 7. The loader/action pattern stays.
Install
npm create react-router@latest my-app
cd my-app
Pick the Cloudflare template at the prompt:
? Which template would you like to use?
> Cloudflare Workers (with D1 + KV)
react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true, // SSR by default
future: {
unstable_viteEnvironmentApi: true,
},
} satisfies Config;
A route file
// app/routes/posts.$slug.tsx
import type { Route } from "./+types/posts.$slug";
export async function loader({ params, context }: Route.LoaderArgs) {
const env = context.cloudflare.env;
const post = await env.DB
.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(params.slug)
.first();
if (!post) throw new Response("Not Found", { status: 404 });
return { post };
}
export async function action({ request, context }: Route.ActionArgs) {
const env = context.cloudflare.env;
const formData = await request.formData();
const email = formData.get("email") as string;
await env.DB.prepare("INSERT INTO subscribers (email) VALUES (?)")
.bind(email)
.run();
return { ok: true };
}
export default function Post({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
Vite config with the Cloudflare plugin
// vite.config.ts
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
reactRouter(),
],
});
wrangler.jsonc
{
"name": "my-app",
"main": "./workers/app.ts",
"compatibility_date": "2026-05-01",
"assets": {
"directory": "./build/client",
"not_found_handling": "single-page-application"
}
}
Note: RR7 SSR needs single-page-application fallback if client-side routing is in play. Astro uses 404-page. This difference matters.
Accessing bindings
// Inside loader / action
export async function loader({ context }: Route.LoaderArgs) {
const env = context.cloudflare.env;
// env.DB, env.KV, env.BUCKET
}
Remix/RR7 gotchas
- React bundle size: ~50KB runtime client. Heavier than Astro for content-heavy sites.
- SSR per request: every request renders inside the Worker. CPU cost. Astro SSG just serves files.
- Streaming: Remix has great streaming support via
<Suspense>. Astro streaming is newer and less polished. - Form action pattern: the strongest among the three. Nested routing + nested data loading.
SvelteKit on Workers
SvelteKit with Svelte 5 and @sveltejs/adapter-cloudflare.
Install
npm create svelte@latest my-app
cd my-app
npm install -D @sveltejs/adapter-cloudflare
svelte.config.js
import adapter from "@sveltejs/adapter-cloudflare";
export default {
kit: {
adapter: adapter({
routes: {
include: ["/*"],
exclude: ["<all>"],
},
}),
},
};
Routing
SvelteKit uses file-system routing with +page.svelte, +page.server.ts, +page.ts:
src/routes/
├── +page.svelte # /
├── blog/
│ ├── +page.svelte # /blog
│ └── [slug]/
│ ├── +page.svelte # /blog/:slug
│ └── +page.server.ts
Data loading
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
export const load: PageServerLoad = async ({ params, platform }) => {
const env = platform?.env;
if (!env) throw error(500, "Missing env");
const post = await env.DB
.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(params.slug)
.first();
if (!post) throw error(404, "Not found");
return { post };
};
API endpoints
// src/routes/api/subscribe/+server.ts
import type { RequestHandler } from "./$types";
import { json } from "@sveltejs/kit";
export const POST: RequestHandler = async ({ request, platform }) => {
const env = platform?.env;
if (!env) return json({ error: "no env" }, { status: 500 });
const { email } = await request.json();
await env.DB.prepare("INSERT INTO subscribers (email) VALUES (?)").bind(email).run();
return json({ ok: true });
};
Per-route prerender
// src/routes/blog/[slug]/+page.server.ts
export const prerender = true; // SSG for this route
Or globally via +layout.server.ts:
export const prerender = "auto"; // mix per-route
Accessing bindings
Bindings come through platform.env:
export const load: PageServerLoad = async ({ platform }) => {
const env = platform?.env;
await env?.DB.prepare("...").run();
};
platform can be undefined in local dev if you aren’t using wrangler. Always null-check.
SvelteKit gotchas
- Svelte 5 runes: new syntax (
$state,$derived,$effect). A mental-model update from Svelte 4. platformundefined in dev:vite devruns without the Workers runtime. Usewrangler devorvite --mode productionwith wrangler.- Bundle size: Svelte runtime is ~20KB, lighter than React. Faster cold start than Remix.
- MDX:
mdsvexfor Markdown-in-Svelte. More setup than Astro Content Collections.
Pattern: framework + Worker APIs side by side
In practice many projects need both framework SSG/SSR and Worker API endpoints with complex logic (JWT auth, webhooks, cron).
Approach 1: everything inside the framework
Every endpoint goes through framework routing (/api/* is an Astro endpoint / Remix action / SvelteKit +server.ts).
Pros: single codebase, one deploy. Cons: framework routing occasionally loses to vanilla for edge cases (binary responses, streaming, OG image generation).
Approach 2: separate Worker for the API
Deploy two Workers: my-blog (framework) + my-blog-api (pure Worker). Use a Service Binding to call between them.
Pros: separation of concerns, each Worker optimised for its workload.
Cons: two deploys, two wrangler.jsonc files, more operational complexity.
Approach 3: framework + custom routes
Astro/SvelteKit allow “ejecting” some routes to pure Worker handlers. This blog uses that pattern:
src/pages/ # Astro SSG
src/pages/api/ # Astro endpoints (subscribe, webmention)
worker/ # Pure Worker code (OG image, OIDC federation, cron)
wrangler.jsonc points main at worker/index.ts instead of Astro’s default. The Worker checks the path: /api/* → Astro handler (delegated via the adapter), /og/*.png → custom handler, everything else → env.ASSETS.fetch().
Details are out of scope, but this is a strong pattern when the framework doesn’t quite stretch.
Why this blog picked Astro
Decision history:
- Content-heavy: 58 markdown posts, not a form-heavy app → SSG fits best.
- SEO critical: static HTML crawls well, no client-JS blockers.
- 0 JS default: Astro islands are opt-in; most pages ship no JS.
- Content Collections: frontmatter schema + types; great for bilingual pairing via
translationKey. - Native MDX: one plugin, no complication.
- Build time ~20s for 58 posts: fast.
- Community: good docs, common patterns.
If the blog were:
- A form-heavy app, admin dashboard: RR7.
- Real-time SSR with personalisation: RR7 or SvelteKit.
- A docs site with interactive demos: Astro or SvelteKit.
There’s no “best framework”. Match the workload.
Static asset + Worker: configs in detail
Astro 5 + the Cloudflare adapter has two modes:
Mode A: pure SSG
{
"name": "my-blog",
"compatibility_date": "2026-05-01",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
}
// NO "main"
}
Entire site is static files. No Worker handler. Fastest.
Mode B: SSG + Worker (this blog)
{
"name": "khavan",
"main": "worker/index.ts",
"compatibility_date": "2026-05-01",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
},
"d1_databases": [...],
"kv_namespaces": [...]
}
Worker handler for /api/*, /admin/*, /og/*.png. Everything else falls back to env.ASSETS.fetch(request).
Mode C: full SSR
{
"name": "my-app",
"main": "./dist/_worker.js/index.js", // Astro-generated
"compatibility_date": "2026-05-01",
"assets": {
"directory": "./dist/client",
"not_found_handling": "single-page-application"
}
}
Worker handler renders HTML per request. SPA fallback for client routing.
Common gotchas
① not_found_handling affects SEO
"404-page" → /nonexistent → dist/404.html, HTTP 404
"single-page-application" → /nonexistent → dist/index.html, HTTP 200
SPA fallback breaks SEO on a static site. Only use it when the site is genuinely a SPA with client-side routing.
② Vite dev vs wrangler dev
# Framework dev — fast, but no real Workers bindings
npm run dev
# Workers dev — slower, bindings simulated by Miniflare
npx wrangler dev ./dist
Dev loop: npm run dev for fast UI iteration, wrangler dev when you need real bindings.
③ process.env doesn’t exist
Workers have no process.env. The framework has to go through bindings.
// WRONG
const apiKey = process.env.API_KEY;
// RIGHT (Astro)
const apiKey = locals.runtime.env.API_KEY;
// RIGHT (Remix/RR7)
const apiKey = context.cloudflare.env.API_KEY;
// RIGHT (SvelteKit)
const apiKey = platform?.env.API_KEY;
④ Build output paths
- Astro:
dist/ - Remix/RR7:
build/client/+build/server/ - SvelteKit:
.svelte-kit/output/+ Cloudflare adapter output lives elsewhere
Match assets.directory in wrangler.jsonc exactly.
⑤ Cold start with framework runtime
- Astro SSG: < 5ms (file serve).
- Astro SSR: 10-20ms (framework init).
- RR7 SSR: 20-40ms (React + framework).
- SvelteKit SSR: 10-20ms (small Svelte runtime).
Not critical, but worth knowing what to expect.
⑥ Framework version compatibility
The Cloudflare runtime evolves (compatibility_date). Adapters may lag. Pin the framework version in package.json and update on a controlled cadence.
Testing framework routes
Whatever framework, the vitest-pool-workers pattern from Part 4 works:
import { SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("Blog routes", () => {
it("GET /blog/hello returns 200", async () => {
const res = await SELF.fetch("https://example.com/blog/hello");
expect(res.status).toBe(200);
});
it("POST /api/subscribe creates subscriber", async () => {
const res = await SELF.fetch("https://example.com/api/subscribe", {
method: "POST",
body: JSON.stringify({ email: "a@b.com" }),
headers: { "Content-Type": "application/json" },
});
expect(res.status).toBe(200);
const { ok } = await res.json();
expect(ok).toBe(true);
});
});
Tests hit the Worker bundle, no framework mocking.
Production checklist
- Rendering model (SSG/SSR/hybrid) matches each route’s workload.
-
not_found_handlingis correct (“404-page” for content sites; “single-page-application” only for SPAs). - Bindings accessed via framework-specific API, never
process.env. - Build output path matches
assets.directoryinwrangler.jsonc. -
siteURL set in framework config for RSS/sitemap. - Cold start measured, not a blocker for your workload.
- Smoke test after deploy: home 200, 404 correct, one API endpoint, one dynamic route.
Wrap-up
Three frameworks, three rendering philosophies. Astro for content-first SSG. Remix/RR7 for form-heavy React SSR. SvelteKit for hybrid + light runtime.
There’s no “best framework for Workers”. There’s a match to your workload.
One more post in Block 3: Part 12 — CI/CD with Wrangler + GitHub Actions. Then we move into Block AI.