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-pagesnhư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
3 mô hình render
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 trongastro.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. platformundefined trong dev: khi dùngvite devkhông có Workers runtime. Dùngwrangler devhoặcvite --mode productionvới wrangler.- Kích thước bundle: runtime Svelte ~20KB, nhẹ hơn React. Cold start nhanh hơn Remix.
- MDX:
mdsvexcho 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:
- Nặng nội dung: 58 bài markdown, không phải ứng dụng nhiều form → SSG hợp nhất.
- SEO quan trọng: HTML tĩnh crawl tốt, không bị JS client cản trở.
- Mặc định 0 JS: island của Astro opt-in, hầu hết trang 0 JS.
- Content Collections: schema frontmatter + type, rất hợp với song ngữ (translationKey).
- MDX native: 1 plugin, không phức tạp.
- Thời gian build ~20s cho 58 bài: nhanh.
- 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.directorytrongwrangler.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.