Astro, Remix, SvelteKit on Workers: adapters and trade-offs

Three full-stack frameworks on Workers differ in rendering, default JS, adapter, bindings. Real setup for each, SSG vs SSR vs hybrid, and why this blog picked Astro.

· 7 min read · Đọc bản tiếng Việt
Astro vs Remix vs SvelteKit on Cloudflare Workers: adapter setup, SSG/SSR/hybrid rendering, binding access patterns for D1/KV/R2, and framework trade-offs

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-pages is 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

Comparison of Astro, Remix (React Router 7), and SvelteKit across 7 criteria: rendering model, default client JS, Workers adapter, how to access bindings, static asset serving, content/MDX support, data loading API, and when to pick which.


3 rendering models

Three rendering models: SSG renders static HTML at build time, SSR renders per request inside the Worker handler, Hybrid mixes them per route (SSG for content, SSR for dynamic). Trade-offs: speed, data freshness, CPU cost.

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.
  • site URL: must be set in astro.config.mjs so 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.
  • platform undefined in dev: vite dev runs without the Workers runtime. Use wrangler dev or vite --mode production with wrangler.
  • Bundle size: Svelte runtime is ~20KB, lighter than React. Faster cold start than Remix.
  • MDX: mdsvex for 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:

  1. Content-heavy: 58 markdown posts, not a form-heavy app → SSG fits best.
  2. SEO critical: static HTML crawls well, no client-JS blockers.
  3. 0 JS default: Astro islands are opt-in; most pages ship no JS.
  4. Content Collections: frontmatter schema + types; great for bilingual pairing via translationKey.
  5. Native MDX: one plugin, no complication.
  6. Build time ~20s for 58 posts: fast.
  7. 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_handling is 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.directory in wrangler.jsonc.
  • site URL 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.


References