Astro, Remix, SvelteKit trên Workers: adapter và trade-off

3 framework full-stack trên Workers khác nhau về render, JS client, adapter và bindings. Thiết lập thực tế từng cái, SSG vs SSR vs hybrid, và vì sao blog này chọn Astro.

· 8 phút đọc · Read in English
So sánh Astro, Remix và SvelteKit trên Cloudflare Workers: adapter cấu hình, mô hình render SSG/SSR/hybrid, cách truy cập binding D1/KV/R2 và các trade-off

TL;DR

3 framework full-stack chạy tốt trên Workers:

  • Astro: SSG mặc định, 0 JS client nếu không dùng islands, Content Collections cho blog/docs.
  • Remix (React Router 7): SSR mặc định, streaming, hệ sinh thái React. Mạnh cho ứng dụng nhiều form động.
  • SvelteKit: hybrid theo từng route, môi trường chạy nhỏ nhất, DX tối giản.

Luận điểm chính:

Lựa chọn framework không do Cloudflare hỗ trợ tốt hay không quyết định (cả 3 đều ổn). Quyết định bởi mô hình render và khối lượng công việc: site nội dung → Astro, ứng dụng động → Remix/RR7, hybrid cần môi trường chạy nhẹ → SvelteKit. Blog này dùng Astro vì 58 bài tĩnh + 8 endpoint API rất hợp với SSG + Workers Assets.

Bài này đi qua: thiết lập thực tế từng framework, 3 mô hình render (SSG/SSR/hybrid), cách truy cập bindings từ mỗi framework, và gotcha thực tế.


Dành cho ai

  • Dev đang chọn framework cho project Workers.
  • Người từ Next.js muốn biết phương án thay thế cho Workers (Next.js Edge runtime không nằm trong phạm vi ở đây).
  • Ai cần quyết định SSG vs SSR cho từng route.

Nên đọc trước: Part 2 (môi trường chạy), Part 4 (vòng lặp dev Wrangler), Part 9 (Router).

Sau bài này bạn sẽ:

  • Thiết lập 3 framework từ đầu trên Workers.
  • Phân biệt SSG / SSR / hybrid và khi nào dùng cái nào.
  • Truy cập bindings (DB, KV, R2, Queue) từ handler framework.
  • Biết gotcha thực tế của từng framework.

Bài này không nói về gì

  • Next.js: có @cloudflare/next-on-pages nhưng đủ phức tạp để cần bài riêng. Workers Assets + Next mới có, chưa chín.
  • Solid / Qwik / Nuxt: các framework khác có adapter Workers nhưng ít phổ biến ở ngữ cảnh này.
  • Tính năng sâu của framework (xác thực, form, suspense): docs mỗi framework đã đủ.

So sánh framework

So sánh Astro, Remix (React Router 7), SvelteKit trên 7 tiêu chí: mô hình render, JS client mặc định, adapter Workers, cách truy cập bindings, phục vụ static asset, hỗ trợ content/MDX, API nạp dữ liệu, khi nào chọn cái nào.


3 mô hình render

3 mô hình render: SSG render HTML tĩnh ở thời điểm build, SSR render mỗi request trong handler Worker, Hybrid trộn theo route (SSG cho nội dung, SSR cho phần động). Đánh đổi: tốc độ, độ tươi dữ liệu, chi phí CPU.

SSG (Static Site Generation)

Render HTML ở thời điểm build. Triển khai file .html vào Workers Assets. Request → Worker phục vụ file qua env.ASSETS.fetch().

npm run build
  → dist/
    ├── index.html
    ├── blog/
    │   ├── hello/index.html
    │   └── world/index.html
    └── _worker.js  (optional, nếu có dynamic route)

Ưu: nhanh nhất, cache CDN tự động, không tốn CPU mỗi request.

Nhược: dữ liệu đóng băng ở thời điểm build. Dữ liệu động phải fetch phía client hoặc rebuild.

SSR (Server-Side Rendering)

Handler Worker render HTML mỗi request. Build tạo _worker.js + các chunk JS client.

npm run build
  → dist/
    ├── _worker.js  (handler chính)
    ├── _astro/  (client chunks, nếu có)
    └── ...

Request → _worker.js → render HTML → stream về client.

Ưu: dữ liệu mỗi request, cá nhân hóa, xác thực, động.

Nhược: tốn CPU mỗi request. Phân tầng cache phức tạp.

Hybrid

Trộn theo route. Astro / SvelteKit / Remix đều hỗ trợ.

/blog/[slug]   → SSG (prerender build time)
/dashboard     → SSR (render per request)
/api/*         → Worker handler thuần

Framework định tuyến thông minh: request file tĩnh phục vụ qua Workers Assets, request route động gọi handler.

Ưu: tốt nhất của cả hai.

Nhược: cấu hình phức tạp, cần hiểu rõ từng route.


Astro 5 trên Workers

Blog này chạy trên Astro + Workers Assets. Thiết lập ~30 phút từ đầu.

Cài đặt

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 default
  adapter: cloudflare(),
  integrations: [mdx(), sitemap()],
});

output: "static" — SSG cho toàn site. Đổi "server" cho SSR thuần, "hybrid" cho kiểu trộn.

Content Collections

Tính năng khác biệt nhất của Astro: nội dung như dữ liệu có type.

// 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 };

Post trong src/content/blog/*.md:

---
title: "Hello World"
pubDate: 2026-05-04
draft: false
---

Content của post.

Astro validate frontmatter, type-safe ở cấp trang:

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

Truy cập bindings

Với Astro + Cloudflare adapter, bindings qua locals.runtime.env trong 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 }));
};

Trong SSG, không có locals.runtime.env (thời điểm build không có binding). Dùng dữ liệu tĩnh hoặc fetch ở thời điểm build.

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" dùng dist/404.html với HTTP 404 (không dùng phương án dự phòng kiểu SPA). Quan trọng cho SEO.

Triển khai

npm run build
npx wrangler deploy

Gotcha Astro

  • Hydrate các island: chỉ component có directive client:* mới kèm JS. <SomeComponent client:load /> = nạp JS ngay, client:visible = lazy, client:idle = defer.
  • Content Collections ở thời điểm build: validate schema lúc build. Frontmatter sai = build fail, không phải lỗi khi chạy.
  • URL site: phải set trong astro.config.mjs để RSS + sitemap sinh ra đúng.

Remix / React Router 7 trên Workers

Remix đã gộp với React Router 7. Vẫn có pattern loader/action.

Cài đặt

npm create react-router@latest my-app
cd my-app

Chọn Cloudflare template khi 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 default
  future: {
    unstable_viteEnvironmentApi: true,
  },
} satisfies Config;

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

Cấu hình Vite với plugin Cloudflare

// 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"
  }
}

Lưu ý: RR7 SSR cần phương án dự phòng single-page-application nếu có định tuyến phía client. Astro dùng 404-page. Khác biệt quan trọng.

Truy cập bindings

// Trong loader / action
export async function loader({ context }: Route.LoaderArgs) {
  const env = context.cloudflare.env;
  // env.DB, env.KV, env.BUCKET
}

Gotcha Remix/RR7

  • Kích thước bundle React: ~50KB runtime client. Với site nặng nội dung, nặng hơn Astro.
  • SSR mỗi request: mỗi request Worker render. Tốn CPU. Ngược lại Astro SSG chỉ phục vụ file.
  • Streaming: Remix hỗ trợ streaming với <Suspense> tốt. Streaming Astro mới hơn, chưa chín bằng.
  • Pattern form action: mạnh nhất trong 3. Định tuyến lồng + nạp dữ liệu lồng.

SvelteKit trên Workers

SvelteKit với Svelte 5 và @sveltejs/adapter-cloudflare.

Cài đặt

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

Pattern route

SvelteKit dùng định tuyến theo hệ file với +page.svelte, +page.server.ts, +page.ts:

src/routes/
├── +page.svelte           # /
├── blog/
│   ├── +page.svelte       # /blog
│   └── [slug]/
│       ├── +page.svelte   # /blog/:slug
│       └── +page.server.ts

Nạp dữ liệu

// 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 };
};

Endpoint API

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

Prerender theo từng route

// src/routes/blog/[slug]/+page.server.ts
export const prerender = true;  // SSG cho route này

Hoặc toàn cục trong +layout.server.ts:

export const prerender = "auto";  // trộn theo từng route

Truy cập bindings

Bindings qua platform.env:

export const load: PageServerLoad = async ({ platform }) => {
  const env = platform?.env;
  await env?.DB.prepare("...").run();
};

platform có thể undefined trong dev local nếu không dùng wrangler. Luôn kiểm tra null.

Gotcha SvelteKit

  • Rune Svelte 5: cú pháp mới ($state, $derived, $effect). Cần cập nhật mental model từ Svelte 4.
  • platform undefined trong dev: khi dùng vite dev không có Workers runtime. Dùng wrangler dev hoặc vite --mode production với wrangler.
  • Kích thước bundle: runtime Svelte ~20KB, nhẹ hơn React. Cold start nhanh hơn Remix.
  • MDX: mdsvex cho Markdown-in-Svelte. Thiết lập phức tạp hơn Content Collections của Astro.

Pattern: framework + Worker API song song

Thực tế nhiều project cần cả framework SSG/SSR và endpoint Worker API có logic phức tạp (JWT auth, webhook, cron).

Phương án 1: tất cả trong framework

Mọi endpoint qua định tuyến framework (/api/* là endpoint Astro / action Remix / +server.ts SvelteKit).

Ưu: một codebase, một lần triển khai. Nhược: định tuyến framework đôi khi thua vanilla cho trường hợp biên (response nhị phân, streaming, sinh ảnh OG).

Phương án 2: Worker riêng cho API

Triển khai 2 Worker: my-blog (framework) + my-blog-api (Worker thuần). Dùng Service Binding để gọi giữa nhau.

Ưu: tách bạch trách nhiệm, mỗi Worker tối ưu cho khối lượng công việc riêng. Nhược: 2 lần triển khai, 2 wrangler.jsonc, phức tạp hơn.

Phương án 3: Framework + route tùy biến

Astro/SvelteKit hỗ trợ “eject” một số route sang handler Worker thuần. Blog này dùng pattern này:

src/pages/         # Astro SSG
src/pages/api/     # Astro endpoint (subscribe, webmention)
worker/            # Worker code thuần (OG image, OIDC federation, cron)

wrangler.jsonc trỏ main sang worker/index.ts thay vì mặc định của Astro. Worker kiểm tra path: /api/* → handler Astro (ủy thác qua adapter), /og/*.png → handler tùy biến, còn lại → env.ASSETS.fetch().

Chi tiết ngoài phạm vi, nhưng đây là pattern mạnh khi framework không đủ linh hoạt.


Blog này tại sao Astro

Lịch sử quyết định:

  1. Nặng nội dung: 58 bài markdown, không phải ứng dụng nhiều form → SSG hợp nhất.
  2. SEO quan trọng: HTML tĩnh crawl tốt, không bị JS client cản trở.
  3. Mặc định 0 JS: island của Astro opt-in, hầu hết trang 0 JS.
  4. Content Collections: schema frontmatter + type, rất hợp với song ngữ (translationKey).
  5. MDX native: 1 plugin, không phức tạp.
  6. Thời gian build ~20s cho 58 bài: nhanh.
  7. Cộng đồng: docs tốt, pattern phổ biến.

Nếu blog là:

  • Ứng dụng nhiều form, admin dashboard: RR7.
  • SSR realtime với cá nhân hóa: RR7 hoặc SvelteKit.
  • Site docs với demo tương tác: Astro hoặc SvelteKit.

Không có “framework tốt nhất”. Khớp khối lượng công việc.


Static asset + Worker: cấu hình chi tiết

Astro 5 + adapter Cloudflare có 2 chế độ:

Chế độ A: SSG thuần

{
  "name": "my-blog",
  "compatibility_date": "2026-05-01",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "404-page"
  }
  // KHÔNG có "main"
}

Toàn site là file tĩnh. Không có handler Worker. Nhanh nhất.

Chế độ B: SSG + Worker (blog này)

{
  "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": [...]
}

Handler Worker cho /api/*, /admin/*, /og/*.png. Còn lại dùng phương án dự phòng env.ASSETS.fetch(request).

Chế độ C: SSR toàn site

{
  "name": "my-app",
  "main": "./dist/_worker.js/index.js",  // Astro generate
  "compatibility_date": "2026-05-01",
  "assets": {
    "directory": "./dist/client",
    "not_found_handling": "single-page-application"
  }
}

Handler Worker render HTML mỗi request. Phương án dự phòng kiểu SPA cho định tuyến phía client.


Gotcha chung

not_found_handling ảnh hưởng SEO

"404-page"            → /nonexistent → dist/404.html, HTTP 404
"single-page-application" → /nonexistent → dist/index.html, HTTP 200

Phương án dự phòng kiểu SPA phá SEO với site tĩnh. Dùng khi site thật sự là SPA với định tuyến phía client.

② Vite dev vs wrangler dev

# Dev framework — nhanh, không có bindings Workers thật
npm run dev

# Dev Workers — chậm hơn, bindings qua Miniflare
npx wrangler dev ./dist

Vòng lặp dev: npm run dev cho lặp UI nhanh, wrangler dev khi test với bindings.

process.env không tồn tại

Worker không có process.env. Framework phải qua binding.

// SAI
const apiKey = process.env.API_KEY;

// ĐÚNG (Astro)
const apiKey = locals.runtime.env.API_KEY;

// ĐÚNG (Remix/RR7)
const apiKey = context.cloudflare.env.API_KEY;

// ĐÚNG (SvelteKit)
const apiKey = platform?.env.API_KEY;

④ Đường dẫn đầu ra build

  • Astro: dist/
  • Remix/RR7: build/client/ + build/server/
  • SvelteKit: .svelte-kit/output/ + adapter Cloudflare cho đầu ra khác

Khớp assets.directory trong wrangler.jsonc cho đúng.

⑤ Cold start với runtime framework

  • Astro SSG: < 5ms (phục vụ file).
  • Astro SSR: 10-20ms (khởi tạo framework).
  • RR7 SSR: 20-40ms (React + framework).
  • SvelteKit SSR: 10-20ms (runtime Svelte nhỏ).

Không quá quan trọng, nhưng biết để dự phòng kỳ vọng.

⑥ Tương thích phiên bản framework

Cloudflare runtime thay đổi (compatibility_date). Adapter framework có thể chưa hỗ trợ phiên bản mới. Ghim phiên bản framework trong package.json, cập nhật có kiểm soát.


Test route framework

Dù framework nào, pattern vitest-pool-workers từ Part 4 vẫn hoạt động:

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

Test qua bundle Worker, không mock framework.


Production checklist

  • Mô hình render (SSG/SSR/hybrid) khớp với khối lượng công việc từng route.
  • not_found_handling đúng (“404-page” cho site nội dung, “single-page-application” chỉ cho SPA).
  • Truy cập bindings qua API đặc thù framework, không process.env.
  • Đường dẫn đầu ra build khớp assets.directory trong wrangler.jsonc.
  • URL site được set trong cấu hình framework cho RSS/sitemap.
  • Cold start đo được, không chặn với khối lượng công việc.
  • Smoke test sau triển khai: home 200, 404 đúng, 1 endpoint API, 1 route động.

Kết

3 framework, 3 triết lý render khác nhau. Astro cho SSG ưu tiên nội dung. Remix/RR7 cho SSR React nhiều form. SvelteKit cho hybrid với môi trường chạy nhẹ.

Không có framework “tốt nhất cho Workers”. Có framework khớp với khối lượng công việc.

Block 3 (Framework) còn 1 part: Part 12 — CI/CD với Wrangler + GitHub Actions. Sau đó bước sang Block AI.


Tham khảo