Bạn có thực sự cần một Single-Page Application nặng nề để tạo ra trải nghiệm web mượt mà?
Trong nhiều năm, React, Vue và Angular thống trị frontend bằng lời hứa về interactivity — nhưng cái giá phải trả là bundle JavaScript khổng lồ, Time-to-Interactive chậm và SEO phức tạp. Năm 2026, một hướng tiếp cận khác đang trỗi dậy mạnh mẽ: Hypermedia-Driven Applications (HDA).
HDA không phủ nhận sức mạnh của HTML — ngược lại, nó khai thác triệt để hypermedia như một application state engine đúng nghĩa. Thay vì để client gánh toàn bộ logic, server trả về HTML thực sự có ngữ nghĩa, còn trình duyệt chỉ việc render.
Trong bài viết này, chúng ta sẽ xây dựng một ứng dụng web hiệu suất cao theo kiến trúc HDA với bộ công cụ được chọn lọc kỹ càng:
- HTMX — giao tiếp hypermedia trực tiếp từ HTML, không cần viết fetch/axios
- Elysia.js — backend server nhanh nhất trên Bun runtime, type-safe từ đầu đến cuối
- Alpine.js — reactive UI nhẹ như gió (~15 KB) cho những tương tác cục bộ
- Workbox — Service Worker layer biến app thành PWA offline-capable
Kết quả? Một ứng dụng load nhanh hơn, SEO-friendly hơn, dễ maintain hơn — và ít JavaScript hơn bạn nghĩ.

Demo: elysia.quicksite.vn
Hypermedia-Driven Applications — Góc nhìn chuyên sâu
Web ban đầu là MPA thuần túy: mỗi click là một round-trip, server trả về HTML hoàn chỉnh, browser render. Đơn giản, nhưng trải nghiệm người dùng thô. SPA ra đời như một phản đề — toàn bộ state chuyển về client, JSON thay HTML, React/Vue/Angular thống trị. UX tốt hơn, nhưng cái giá: bundle nặng, SEO khó, TTFB tệ, mental model phức tạp. HDA là tổng đề — lấy điều tốt nhất từ cả hai, loại bỏ điều tệ nhất của cả hai.
Tại sao HDA không phải “quay về quá khứ”?
Đây là điểm mà nhiều người nhầm nhất.
HDA không phải MPA truyền thống. Sự khác biệt nằm ở chỗ: MPA truyền thống bị giới hạn bởi những gì HTML gốc cho phép — chỉ <a> và <form> mới có thể phát HTTP request, chỉ GET và POST, chỉ thay thế toàn bộ trang. HDA mở rộng chính HTML như một hypermedia system — bất kỳ element nào cũng có thể trigger request, bất kỳ HTTP verb nào (PUT, DELETE, PATCH), và response chỉ cần cập nhật một fragment nhỏ trong DOM.
Đây là sự mở rộng ngữ nghĩa, không phải thay thế. HTML vẫn là ngôn ngữ nói chuyện chính giữa client và server.
Hai ràng buộc kiến trúc
Constraint 1: Declarative HTML-embedded syntax
<input hx-post="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results">Nhìn vào đây. Không có một dòng JavaScript nào. Nhưng bạn vừa có live search với debounce 500ms. Toàn bộ ý định được viết ngay trên element, không phải trong một file .js nào đó cách xa hàng trăm dòng.
Đây là nguyên lý Locality of Behavior — hành vi nằm ngay tại nơi nó xảy ra. Nó đối lập hoàn toàn với SPA, nơi để hiểu một button làm gì, bạn phải trace qua: JSX → event handler → Redux action → reducer → selector → re-render. HDA giết chết toàn bộ chuỗi trace đó.
Constraint 2: Server giao tiếp bằng HTML, không phải JSON
Đây là constraint triệt để nhất và cũng bị hiểu sai nhiều nhất.
Trong SPA: client gọi /api/users, server trả JSON, client parse, client render. Server và client giờ phải đồng thuận về data contract — và khi contract thay đổi, cả hai phải thay đổi đồng bộ. Đây là nguồn gốc của coupling ngầm.
Trong HDA: client gọi /search, server trả về một đoạn HTML đã render sẵn, htmx cắm thẳng vào DOM. Không có data contract. Không có client-side rendering logic. Server là source of truth tuyệt đối, kể cả về presentation.
Khi server thay đổi cách hiển thị danh sách? Client không cần biết. Nó chỉ nhận HTML mới. Đây là loose coupling thực sự.
HATEOAS — khái niệm bị lãng quên mà HDA hồi sinh
HDA tiếp tục sử dụng Hypermedia As The Engine Of Application State (HATEOAS), trong khi hầu hết SPA từ bỏ HATEOAS để nghiêng về client-side model và data API. htmx
HATEOAS là một trong những ý tưởng đẹp nhất của Fielding, nhưng hầu như bị toàn bộ cộng đồng REST API bỏ qua. Ý tưởng cốt lõi: application state không được lưu trong client — nó được encode trong hypermedia response từ server.
Ví dụ thực tế: Khi server trả về một trang sản phẩm, nếu người dùng đã đăng nhập, HTML có nút “Mua ngay”. Nếu chưa đăng nhập, HTML có nút “Đăng nhập để mua”. Logic authorization nằm hoàn toàn ở server. Client không cần biết trạng thái auth để quyết định render gì — server đã quyết định rồi.
SPA phá vỡ điều này bởi vì client phải tự hỏi: “User có permission không?” → gọi /api/me → parse roles → conditionally render. Giờ logic authorization bị duplicate ở cả hai đầu.
Code-On-Demand: constraint thứ ba — và tại sao Alpine.js fit perfectly
Kiến trúc HDA có một constraint cuối cùng, tùy chọn: Code-On-Demand (scripting) nên được thực hiện trực tiếp trong primary hypermedia. htmx
Đây là chỗ Alpine.js tỏa sáng. Nhìn lại ví dụ:
<!-- Alpine JS -->
<button @click="open = !open" :class="{'red-border' : open, '' : !open}">
Toggle Class
</button>Alpine không tạo ra một component system tách biệt khỏi HTML. Nó sống trong HTML. State, behavior, binding — tất cả khai báo ngay trên element. Đây chính xác là tinh thần Locality of Behavior mà Gross đề xuất.
Ngược lại, React component là một đảo biệt lập — nó có state riêng, lifecycle riêng, và HTML chỉ là output của nó. Triết lý ngược hoàn toàn: thay vì HTML là primary medium và script là enhancement, React làm script là primary medium và HTML là output.
Tại sao HDA thực sự quan trọng với production systems?
Đây là góc nhìn mà essay không đi sâu, nhưng cực kỳ quan trọng với engineers:
Deployment simplicity: HDA server render HTML → không cần CDN phức tạp cho assets, không cần hydration, không cần SSR/SSG build pipeline. Deploy một binary Elysia.js là xong.
Progressive enhancement tự nhiên: Vì HTML có nghĩa ngay cả khi JavaScript bị tắt, HDA app gracefully degrade. SPA mà tắt JS là màn hình trắng.
Network resilience: Workbox + Service Worker trong HDA stack có thể cache HTML responses — offline experience không cần code path đặc biệt.
Observability: Khi mọi state change đều là một HTTP request với HTML response, bạn có thể log, replay, debug bằng DevTools Network tab đơn giản. Không cần Redux DevTools, không cần React Profiler.
Security surface nhỏ hơn: Không expose JSON API → không có endpoint nào bị scrape hay bị gọi từ ngoài. Server trả HTML, HTML không có giá trị với attacker theo cách JSON data có.
Giới hạn thực tế — không né tránh
HDA không phải silver bullet. Có những bài toán nó không phù hợp:
- Real-time collaborative apps (Google Docs, Figma): State cực kỳ granular, WebSocket-driven, không thể model bằng HTML responses.
- Offline-first heavy apps: Nếu app phải hoạt động hoàn toàn offline với full write capability, client-side state là bắt buộc.
- Rich client-side computation: Canvas games, audio processing, data visualization phức tạp — đây là địa phận của JavaScript thuần, không phải hypermedia.
HDA tỏa sáng ở CRUD-heavy business applications — dashboards, admin panels, e-commerce, content platforms — tức là 80% web apps thực tế ngoài kia.
Tổng quan ngắn gọn về kiến trúc HDA
Kiến trúc HDA cố gắng kết hợp ưu điểm của cả hai: sự đơn giản và độ tin cậy của MPA với kiến trúc RESTful sử dụng Hypermedia As The Engine Of Application State, trong khi vẫn cung cấp trải nghiệm người dùng tốt hơn, ngang bằng SPA trong nhiều trường hợp. htmx
Nhưng đây là điều Gross không nói thẳng ra: HDA là một tuyên ngôn chống lại sự phức tạp không cần thiết. Cả một thế hệ engineers đã bị thuyết phục rằng JavaScript nhiều hơn đồng nghĩa với app tốt hơn. HDA challenge điều đó ở cấp độ triết học, không chỉ kỹ thuật.
Kiến trúc Elysia.js + Bun và deploy Cloudflare Workers — Góc nhìn shuyên sâu
Phần 1: Tại sao Bun không chỉ là “Node.js nhanh hơn”?
Đây là nhầm lẫn phổ biến nhất. Bun không phải Node với engine mới — nó là một platform hoàn toàn khác được viết bằng Zig, xây trên JavaScriptCore (engine của Safari) thay vì V8.

Ba tầng tạo nên tốc độ của Bun:
┌──────────────────────────────────────┐
│ JavaScriptCore (JSC) │ ← Safari engine, JIT aggressive hơn V8
│ Zig I/O Loop │ ← Không dùng libuv, I/O native syscall
│ uWebSockets HTTP Stack │ ← Không phải http.createServer của Node
└──────────────────────────────────────┘Bun là hơn cả “một runtime khác” — nó bundle JavaScriptCore engine, Zig-written I/O loop, và uWebSockets-based HTTP stack, cho throughput cao hơn 4–18× so với Node + Express. Các framework Node truyền thống như Express, Fastify, NestJS có thể chạy trên Bun, nhưng chúng bỏ lại hiệu năng trên bàn vì không được tối ưu cho startup pipeline hay native TypeScript transpiler của Bun. Brightcoding
Điều này có nghĩa là: Elysia là framework duy nhất được thiết kế để khai thác toàn bộ những tầng này từ đầu.
Phần 2: Kiến trúc nội tại của Elysia — Ahead-of-Time Compilation
Đây là điểm kỹ thuật quan trọng nhất mà hầu hết developer bỏ qua.

Elysia dùng static code analysis và Ahead of Time compilation để generate optimized code on the fly. Nghe có vẻ abstract — hãy cụ thể hóa: ElysiaJS
Khi bạn viết:
const app = new Elysia()
.get('/user/:id', ({ params, query }) => {
return db.getUser(params.id)
}, {
params: t.Object({ id: t.Number() }),
query: t.Object({ filter: t.Optional(t.String()) })
})Elysia không tạo ra một generic request handler dùng chung cho mọi route. Thay vào đó, lúc .listen() được gọi, nó compile một hàm riêng cho route này — một hàm biết chính xác:
- Chỉ parse
params.idvàquery.filter, bỏ qua phần còn lại - Validate
idlà number ngay lúc parse, không gọi validator sau - Không allocate object cho những field không được dùng
Kết quả là zero-overhead abstraction — abstraction cao nhưng runtime cost như viết thẳng.

Elysia deliver: static-code analysis, dead-code elimination, pre-compiled validation, và zero-copy where possible. Brightcoding
Phần 3: Type Safety — Single Source of Truth thực sự
Đây là điểm Elysia khác hoàn toàn với Express + Zod hay Fastify + JSON Schema:
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/user', ({ body }) => body, {
body: t.Object({
name: t.String(),
age: t.Number({ minimum: 0 })
}),
response: t.Object({ id: t.Number() })
})Elysia infers TypeScript types tự động; runtime + compile-time validation từ một single schema duy nhất (TypeBox). Schema body đồng thời là: TypeScript type cho IDE, runtime validator cho request đến, và OpenAPI schema cho Swagger docs — không cần viết lại ba lần. Brightcoding
Eden Treaty — client được generate tự động từ Elysia instance:
// Client-side — type an toàn end-to-end, không codegen
import { treaty } from '@elysiajs/eden'
import type { App } from './server'
const api = treaty<App>('localhost:3000')
const { data, error } = await api.user.post({
name: 'Nguyen Van A',
age: 25
})
// data được TypeScript infer chính xác là { id: number }Không cần tRPC. Không cần GraphQL. Không cần OpenAPI codegen. Type flow từ server sang client là native TypeScript inference.
Phần 4: WinterTC — Chìa khóa để deploy mọi nơi
WinterTC là standard cho HTTP server chạy trên Cloudflare, Deno, Vercel, và others. Nó cho phép web server chạy interoperably across runtimes bằng cách dùng Request và Response. Elysia là WinterTC compliant — được optimize cho Bun nhưng support runtimes khác khi có thể. ElysiaJS
Điều này có nghĩa là Elysia app về cơ bản là một hàm:
(Request) => Promise<Response>Đây là Web Standard API — không phụ thuộc Node.js, không phụ thuộc Bun. Bất kỳ runtime nào hiểu Request/Response đều chạy được Elysia.
Phần 5: Deploy Cloudflare Workers — Chi tiết kỹ thuật
CloudflareAdapter và AoT Compilation
Kể từ Elysia 1.4.7, bạn có thể dùng Ahead of Time Compilation với Cloudflare Worker thông qua CloudflareAdapter. ElysiaJS
// src/index.ts
import { Elysia } from 'elysia'
import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'
export default new Elysia({ adapter: CloudflareAdapter })
.get('/', () => 'Hello from the Edge!')
.get('/api/hello', ({ query }) => ({
message: `Hello ${query.name ?? 'World'}`,
region: 'edge'
}))
.compile() // ← BẮT BUỘC — trigger AoT compilation.compile() là điểm khác biệt quan trọng. Trên Bun server truyền thống, Elysia compile khi .listen() được gọi. Trên Cloudflare Workers, không có .listen() — .compile() kích hoạt quá trình tương đương.
Cấu hình Wrangler
// wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hda-backend",
"main": "src/index.ts",
"compatibility_date": "2025-06-01", // ← Bắt buộc >= 2025-06-01
// Static files cho HDA pattern (HTML fragments)
"assets": {
"directory": "public"
},
// Bindings
"kv_namespaces": [
{ "binding": "CACHE", "id": "your-kv-id" }
],
"d1_databases": [
{ "binding": "DB", "database_name": "hda-db", "database_id": "your-d1-id" }
]
}Bạn không cần flag nodejs_compat vì Elysia không dùng Node.js built-in modules nào. Và Elysia.file cùng Static Plugin không hoạt động do thiếu fs module — dùng assets directory trong wrangler config thay thế. ElysiaJS
Dùng Cloudflare Bindings trong Elysia
import { Elysia } from 'elysia'
import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'
import { env } from 'cloudflare:workers'
export default new Elysia({ adapter: CloudflareAdapter })
// KV — cache HTML fragments cho HDA
.get('/fragments/user/:id', async ({ params }) => {
const cached = await env.CACHE.get(`user:${params.id}`)
if (cached) return new Response(cached, {
headers: { 'Content-Type': 'text/html' }
})
const user = await env.DB
.prepare('SELECT * FROM users WHERE id = ?')
.bind(params.id)
.first()
const html = renderUserFragment(user) // trả về HTML string
await env.CACHE.put(`user:${params.id}`, html, { expirationTtl: 300 })
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
})
})
.compile()Đây là pattern lý tưởng cho HDA: server render HTML fragment, cache vào KV, HTMX fetch về cắm vào DOM. Zero JavaScript bundle cho logic này.
Phần 6: Kiến trúc hoàn chỉnh HDA Stack trên Edge
┌─────────────────────────────────────────────────────────┐
│ Cloudflare Network │
│ │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ Browser │ │ Cloudflare Workers │ │
│ │ │ │ │ │
│ │ HTML page │◄───│ Elysia.js (CloudflareAdapter)│ │
│ │ + HTMX │ │ ├─ Route handlers │ │
│ │ + Alpine │───►│ ├─ TypeBox validation │ │
│ │ + Workbox │ │ ├─ HTML fragment rendering │ │
│ └─────────────┘ │ └─ Response<HTML> │ │
│ └──────────┬─────────────────────┘ │
│ │ │
│ ┌─────────────────┼──────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ │
│ │Cloudflare│ │ D1 │ │ R2 │ │
│ │ KV │ │(SQLite) │ │ (Assets) │ │
│ │(HTML │ │ │ │ │ │
│ │ cache) │ └──────────┘ └──────────┘ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────┘Data flow:
- Browser load trang → Cloudflare Workers trả HTML đầy đủ (SSR)
- User tương tác → HTMX gửi request lên Workers
- Workers query D1, render HTML fragment, cache vào KV
- HTMX nhận HTML fragment, cắm vào DOM — không có JSON, không có client render
- Workbox intercept request, serve từ cache nếu offline
Phần 7: Gotchas quan trọng khi deploy
Giới hạn CPU time: Workers có CPU time limit (10ms free, 30s paid). Elysia AoT compile giảm overhead đáng kể — mỗi request handler ít tốn CPU hơn vì code đã được flatten lúc compile.
Không có file system: Bun.file(), fs.readFile() — không hoạt động. Dùng R2 để lưu static assets lớn, dùng assets directory trong wrangler cho files nhỏ.
Cold start: Elysia có thể register 10,000 routes trong 78ms — trung bình 0.0079ms/route. Trên Workers, cold start không phải vấn đề đáng lo với Elysia. ElysiaJS
Plugin compatibility: Một số plugin của Elysia dùng Bun-specific API — luôn test với wrangler dev trước khi deploy, không phải bun dev.
Nói ngắn gọn
Elysia được dùng trong production bởi nhiều công ty và projects trên toàn thế giới, và được dùng bởi hơn 10,000 open-source projects trên GitHub. ElysiaJS
Stack Elysia + Cloudflare Workers là một trong những kiến trúc cost-efficient nhất hiện tại cho HDA applications: không có server để maintain, billing theo request, global edge network tự động, và latency gần như zero với user ở mọi region.
Khi kết hợp với HTMX pattern — server trả HTML fragments thay vì JSON — bạn có một pipeline mà mỗi tầng đều tối ưu cho throughput: Elysia compile handler thành code tối giản, Workers chạy ở edge gần user nhất, HTMX chỉ update fragment thay vì re-render toàn trang. Đây không phải hype — đây là engineering có chủ đích.
Headless WordPress + Elysia + Bun + HTMX + Alpine.js — Phân tích chuyên sâu: Lợi, hại & bẫy thực tế
Đặt vấn đề: Tại sao đây là một quyết định kiến trúc không tầm thường
Trước khi đi vào pros/cons, cần hiểu rõ tension cơ bản của stack này:
WordPress (PHP, động, nặng, REST/JSON)
↕ ← Điểm ma sát lớn nhất
Elysia (Bun, static AoT, siêu nhẹ, HTML responses)
↕
HTMX (Hypermedia-driven, muốn HTML không phải JSON)WordPress và HTMX có triết lý ngược chiều nhau. WordPress sinh ra JSON; HTMX muốn HTML. Elysia đứng giữa làm translation layer — và đây vừa là điểm mạnh nhất, vừa là nguồn gốc của mọi khó khăn.

Phần 1: Lý do chọn WordPress — Những lợi thế thực sự
Content Management không thể đánh bại
Không có headless CMS nào trên thị trường hiện tại có ecosystem nội dung phong phú bằng WordPress:
- ACF (Advanced Custom Fields) — custom data schema mà không cần viết một dòng PHP
- Yoast SEO — meta tags, sitemaps, schema.org tự động generate, expose qua REST API
- WooCommerce — nếu dự án có eCommerce, đây là lý do đủ để chọn WP
- Gutenberg blocks — editor trực quan mà editorial team không cần training nhiều
Editorial team — những người thực sự tạo nội dung — tiếp tục dùng giao diện quen thuộc. Đây là political advantage không kém phần quan trọng so với technical advantage.
REST API đủ tốt cho 80% use cases
GET /wp-json/wp/v2/posts?_fields=id,title,slug,excerpt,featured_media&per_page=10_fields parameter là underrated nhất của WP REST API. Thay vì nhận một object 40+ fields, bạn chỉ lấy đúng những gì cần. Elysia fetch endpoint này, transform thành HTML fragment, trả về cho HTMX — pipeline gọn.
WordPress làm CMS, Elysia làm BFF — separation of concerns rõ ràng
BFF (Backend For Frontend) là pattern phù hợp nhất cho stack này. Elysia không phải “pass-through proxy” — nó là tầng có trách nhiệm riêng:
WordPress → nguồn dữ liệu thô (JSON)
Elysia BFF → transform JSON → HTML fragment
aggregate nhiều WP endpoints
cache, auth, rate limiting
business logic
HTMX client → nhận HTML, cắm vào DOMKhi WordPress thay đổi data structure? Chỉ Elysia cần update. HTMX client không biết gì và không cần biết gì.
Webhook-driven invalidation — real-time mà không cần WebSocket
WordPress có Actions/Hooks system. Khi post được publish/update:
// WordPress side
add_action('save_post', function($post_id) {
wp_remote_post('https://your-elysia-worker.workers.dev/webhooks/invalidate', [
'body' => json_encode(['post_id' => $post_id, 'action' => 'updated'])
]);
});Elysia nhận webhook, invalidate KV cache tương ứng. Nội dung mới xuất hiện ngay lập tức mà không cần polling. Đây là architecture sạch và hiệu quả.
Phần 2: Những điểm hại thực sự
WordPress REST API chậm một cách hệ thống
Một trong những vấn đề đầu tiên khi dùng setup headless là tốc độ (hay đúng hơn là sự thiếu tốc độ) của WordPress REST API. Các call đến API mất thời gian đáng kể, và trong headless setup, toàn bộ data phải đi qua đó. Những lần thử đầu tiên dẫn đến REST API làm crash test server — trông giống như một cuộc tấn công DOS. Medium
Nguyên nhân cấu trúc: mỗi REST request khởi động toàn bộ WordPress bootstrap — load tất cả plugins, themes, hooks — ngay cả khi bạn chỉ fetch 10 post titles. PHP không có persistent process model như Node/Bun. Mỗi request là một cold start PHP mới.
Trong khi Elysia xử lý hàng nghìn requests/giây, WordPress backend có thể chỉ chịu được vài chục concurrent requests trước khi PHP-FPM worker pool bị cạn kiệt.
Impedance mismatch giữa JSON và Hypermedia
REST API verbose, over-fetching data, và chậm hơn ở scale lớn. Troubleshoothub
Đây là vấn đề triết học gặp vấn đề kỹ thuật. HTMX muốn server trả HTML. WordPress trả JSON. Elysia phải:
- Fetch JSON từ WordPress
- Parse JSON
- Render HTML template với data đó
- Trả HTML về cho HTMX
Bước 3 là nơi mọi thứ trở nên phức tạp. Bạn cần một server-side templating strategy trong Elysia — không phải thứ framework này được thiết kế sẵn để làm.
Cache invalidation là bài toán ba thể
Trong headless architectures, caching phức tạp hơn đáng kể. Frontend, CDN và WordPress đều có thể cache responses. Khi một post được update, tất cả các tầng đó cần biết. Pantheon
Với stack này, bạn có bốn tầng cache cần đồng bộ:
Tầng 1: WordPress object cache (Redis/Memcached)
Tầng 2: Elysia in-memory cache / Cloudflare KV
Tầng 3: Cloudflare CDN cache (HTML responses)
Tầng 4: Workbox Service Worker cache (browser)Khi editor update một bài viết, tất cả bốn tầng phải được invalidate đúng thứ tự. Làm sai → nội dung cũ hiển thị với user mà developer không biết tại sao.
Authentication flow phức tạp hơn nhiều so với SPA thông thường
WordPress dùng cookie-based auth cho admin. REST API dùng Application Passwords hoặc JWT. HTMX gửi HTML form requests. Ba paradigm khác nhau cần được bridge:
- Comment form: HTMX POST → Elysia → WP REST API với auth header
- Protected content: Session ở đâu? Cookie của WordPress không có nghĩa với Cloudflare Worker
- Preview mode: WordPress draft preview cần auth token riêng
Những khó khăn cụ thể developer sẽ gặp — Theo thứ tự xuất hiện
Khó khăn #1: Server-side templating trong Elysia
Elysia không có built-in template engine. Trả HTML từ Elysia cho HTMX yêu cầu bạn phải tự xây dựng hoặc tích hợp:
// Cách naive — ĐỪNG làm thế này trong production
app.get('/fragments/posts', async () => {
const posts = await fetchFromWP('/wp/v2/posts?_fields=id,title,slug')
return `<div>${posts.map(p => `<h2>${p.title.rendered}</h2>`).join('')}</div>`
})Vấn đề: không có escaping, không có component reuse, không có type safety cho template. Bạn cần một strategy rõ ràng:
// Option A: JSX-like với kitajs/html (zero runtime, pure strings)
import html from '@kitajs/html'
const PostCard = ({ title, slug, excerpt }: Post) => (
<article hx-get={`/fragments/post/${slug}`} hx-target="main">
<h2>{title.rendered}</h2>
<p safe>{excerpt.rendered}</p>
</article>
)
// Option B: Template literals với tagged templates
// Option C: Eta.js, Nunjucks (thêm dependency)@kitajs/html là lựa chọn tốt nhất cho stack này — compile-time JSX thành strings, không có runtime overhead, và TypeScript-aware.
Khó khăn #2: WordPress API cold start và timeout
Nhiều backends trông chậm vì mỗi editor action trigger thêm requests, làm tăng response time. Large postmeta tables không có index phù hợp tạo ra long JOINs và làm single-post views bị stall. Webhosting
Cloudflare Workers có timeout cứng. WordPress API đôi khi response trong 2–5 giây (đặc biệt với shared hosting). Nếu Elysia Worker chờ WordPress quá lâu → Worker timeout → user thấy lỗi.
Giải pháp bắt buộc: Stale-while-revalidate pattern:
app.get('/fragments/posts', async ({ set }) => {
// Luôn trả cached content ngay lập tức
const cached = await env.KV.get('posts:fragment', 'text')
if (cached) {
// Background refresh — không block response
ctx.waitUntil(refreshPostsCache())
set.headers['X-Cache'] = 'HIT'
return new Response(cached, { headers: { 'Content-Type': 'text/html' } })
}
// Cache miss — fetch và cache
const fresh = await buildPostsFragment()
await env.KV.put('posts:fragment', fresh, { expirationTtl: 300 })
return new Response(fresh, { headers: { 'Content-Type': 'text/html' } })
})Khó khăn #3: HTMX + Alpine.js scope conflict
Alpine và HTMX đều muốn quản lý DOM mutations. Khi HTMX swap một fragment vào DOM, Alpine components bên trong fragment đó chưa được initialized.
<!-- Fragment được HTMX inject vào -->
<div x-data="{ open: false }"> <!-- Alpine chưa biết element này tồn tại -->
<button @click="open = !open">Toggle</button>
</div>Alpine sẽ không tự động init component trong fragment mới. Bạn cần:
// Lắng nghe HTMX event và reinitialize Alpine
document.addEventListener('htmx:afterSwap', (event) => {
Alpine.initTree(event.detail.target)
})Hoặc cấu hình đúng trong Alpine:
document.addEventListener('alpine:init', () => {
// Đảm bảo Alpine observe DOM mutations
})Đây là bug âm thầm nhất — Alpine component load nhưng không reactive, không có error, developer mất giờ đồng hồ debug.
Khó khăn #4: Gutenberg Block rendering
WordPress 5.9+ expose Gutenberg block content qua REST API dưới dạng JSON. Nhưng content.rendered chỉ là HTML string — đã render sẵn phía PHP. Đây vừa là lợi vừa là hại:
{
"content": {
"rendered": "<p>Nội dung đã render...</p><figure class=\"wp-block-image\">...</figure>",
"raw": "<!-- wp:paragraph -->..."
}
}rendered an toàn để inject thẳng vào DOM — nhưng nó kéo theo CSS classes của Gutenberg (wp-block-*, has-text-align-center…). Nếu bạn không load WordPress’s style.css, toàn bộ block styling bị vỡ.
WordPress 5.9+ expose block content qua REST API dưới dạng JSON, nhưng có limitations khi render Gutenberg blocks trong headless frontend. Ben Ryan
Giải pháp: chỉ import Gutenberg’s base block styles, không phải toàn bộ WordPress stylesheet:
<link rel="stylesheet" href="https://your-wp.com/wp-includes/css/dist/block-library/style.min.css">Khó khăn #5: Real-time preview cho editors
Editor muốn xem preview trước khi publish. WordPress preview dùng cookie auth và query parameter ?preview=true. Nhưng Elysia Worker nhận request này, fetch từ WP với auth — rồi WP trả gì? Draft content đằng sau authentication wall.
Flow phức tạp:
Editor click Preview trong WP Admin
↓
WP tạo preview nonce token
↓
Redirect đến frontend URL với ?preview_nonce=xxx
↓
Elysia Worker nhận request
↓
Elysia gọi WP REST API với nonce trong header
↓
WP trả draft content (nếu nonce valid)
↓
Elysia render HTML và trả vềImplement không đúng → preview không hoạt động → editor team phàn nàn liên tục.
Khó khăn #6: CORS và Cookie trên Cloudflare Workers
Backend performance requirements thực sự thấp hơn trong headless setup vì WordPress không render HTML — chỉ serve JSON. Nhưng cần cấu hình: WordPress host phải cho phép CORS headers mà frontend cần. Hầu hết managed hosts xử lý điều này, nhưng một số cần cấu hình thủ công trong Nginx hoặc qua plugin. Ben Ryan
Cloudflare Worker domain (your-app.workers.dev) khác với WordPress domain (your-wp.com). Khi HTMX POST comment hoặc form data:
Browser → workers.dev (HTMX request)
↓
workers.dev → your-wp.com (server-to-server, không có CORS issue)Server-to-server không có CORS. Nhưng nếu có bất kỳ client-side request nào thẳng lên WP → CORS header phải được configure đúng trên WP side.
Phần 4: Ma trận quyết định — Khi nào nên chọn, khi nào không?
| Tiêu chí | CHỌN | ĐỪNG chọn |
|---|---|---|
| Team | Editorial team đã quen WordPress | Team chưa biết WordPress |
| Dev team biết PHP để customize | Toàn bộ team frontend thuần | |
| Content | Blog, news, portfolio, docs | Real-time data (stocks, chat) |
| Multi-author, workflow phức tạp | User-generated content phức tạp | |
| Có sẵn nội dung WordPress cần migrate | Greenfield với data model đơn giản | |
| Scale | Medium traffic (< 100k req/day) | High-frequency writes |
| Mostly read, ít write | Highly personalized per-user | |
| Budget | WordPress hosting + Cloudflare là acceptable | Cost-sensitive (WP hosting tốt thường đắt) |
Phần 5: Architecture đề xuất để giảm thiểu rủi ro
┌─────────────────────────────────────────────────────────────┐
│ Recommended Stack │
│ │
│ WordPress (WP Engine / Kinsta) │
│ ├─ WPGraphQL plugin (thay REST API cho complex queries) │
│ ├─ ACF + ACF to REST API │
│ ├─ JWT Auth plugin │
│ └─ Webhook on save_post → Elysia invalidation endpoint │
│ │ │
│ Elysia BFF (Cloudflare Workers) │
│ ├─ GraphQL client → WP (batch queries, no over-fetching) │
│ ├─ @kitajs/html → render HTML fragments │
│ ├─ Cloudflare KV → stale-while-revalidate cache │
│ ├─ Cloudflare D1 → session, user preferences │
│ └─ /webhooks/invalidate → selective cache purge │
│ │ │
│ Frontend (static HTML + HTMX + Alpine.js) │
│ ├─ Cloudflare Pages → serve static shell │
│ ├─ HTMX → fetch HTML fragments từ Elysia Workers │
│ ├─ Alpine.js → local interactivity, initialized on swap │
│ └─ Workbox → cache HTML fragments offline │
└─────────────────────────────────────────────────────────────┘Điểm then chốt: Dùng WPGraphQL thay REST API cho phép batch nhiều queries thành một request, chỉ lấy đúng fields cần, và giảm số lần WordPress phải bootstrap.
Lời kết thực dụng
Headless WordPress là architectural trade-off thực sự, không phải upgrade đơn giản. Performance gains là thật. Operational headaches cũng thật. Expect 30–50% development time nhiều hơn cho comparable scope — bạn đang build và maintain hai systems thay vì một. Ben Ryan
Với stack cụ thể này, có thêm một lớp phức tạp nữa: Elysia phải làm thứ nó không được thiết kế để làm — server-side HTML rendering như một template engine. Framework được tối ưu cho API responses, không phải HTML generation.
Nhưng đây là lý do stack này vẫn có giá trị: khi nó hoạt động đúng, bạn có một CMS mà editorial team yêu thích, một backend xử lý hàng triệu requests với Cloudflare edge network, và một frontend load nhanh đến mức user không nhận ra có round-trip xảy ra. Cái khó là cái giá phải trả cho cái tốt nhất của cả hai thế giới.
Tại sao chọn Workbox chứ không phải TanStack Router/Query?
TanStack Router/Query là những thư viện xuất sắc — cho đúng bài toán của chúng.

Bài toán đó là: quản lý state và routing trong một React SPA fetch JSON từ API.
Bài toán của chúng ta là: tăng cường một Hypermedia-Driven Application phục vụ HTML fragments.
Hai bài toán này không chỉ khác nhau về độ lớn hay phức tạp. Chúng mâu thuẫn nhau về triết học cơ bản. Chọn TanStack cho HDA stack không phải “thêm tool tốt hơn” — nó là phá vỡ kiến trúc từ bên trong.
Phần 1: Hiểu TanStack thực sự làm gì
Để so sánh công bằng, phải hiểu TanStack giải quyết vấn đề gì.
TanStack Query — giải quyết bài toán của SPA
TanStack Query cung cấp declarative caching: tạo một query key và data tự động được giữ fresh thông qua background re-validation — không cần reducers, thunks hay normalisation. Components có thể subscribe vào một slice của cached data, giảm re-render costs. Bugragulculer
Vấn đề nó giải quyết xuất phát từ SPA architecture:
SPA Problem:
Browser ─── fetch JSON ──► API
│
▼
Client phải tự hỏi:
"Data này có stale chưa?"
"Có cần refetch không?"
"Có bao nhiêu components đang dùng data này?"
"Khi nào thì garbage collect?"
TanStack Query trả lời tất cả câu hỏi trên.TanStack Router — giải quyết bài toán client-side navigation
TanStack Router coi search params như là global state và cung cấp features để quản lý chúng một cách type-safe với validation capabilities. Medium
TanStack Router có built-in caching layer hoạt động liền mạch với Router, loosely based trên TanStack Query. Data được cached để reuse, invalidated khi cần, và garbage collected khi không dùng. TanStack
Nói cách khác: TanStack Router quản lý client-side navigation state — URL, history, params, loader data — trong một React component tree.
Điểm then chốt: Cả hai đều giả định client là nơi quyết địn
// TanStack Query — client quyết định fetch cái gì
const { data: posts } = useQuery({
queryKey: ['posts', { page, filter }],
queryFn: () => fetch(`/api/posts?page=${page}&filter=${filter}`).then(r => r.json())
})
// TanStack Router — client quyết định render route nào
const router = createRouter({
routeTree: rootRoute.addChildren([postsRoute, postRoute])
})Client là engine. Server là data source bị động. Đây là SPA mental model.
Phần 2: HTMX + HDA đảo ngược hoàn toàn mental model đó
Trong HDA stack của chúng ta:
<!-- Server quyết định HTMX làm gì tiếp theo -->
<input hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-push-url="true">HDA Flow:
Browser ─── HTTP GET /search?q=foo ──► Elysia Worker
│
Fetch từ WP
Render HTML
│
◄── <div id="results">
<article>...</article>
<article>...</article>
</div>
Browser cắm HTML vào DOM. Xong.
Không có state. Không có cache invalidation.
Không có component lifecycle.Server là engine. Client chỉ là display. Đây là Hypermedia mental model theo đúng Fielding.
Khi bạn bring TanStack vào đây, bạn đang cố inject SPA mental model vào một kiến trúc được thiết kế để loại bỏ nó.
Phần 3: Nếu dùng TanStack Query, bạn sẽ phá vỡ điều gì?
Vấn đề 1: TanStack Query không biết HTML fragments tồn tại
TanStack Query cache theo queryKey và lưu trữ JSON data. Nhưng trong HDA stack, server trả về HTML string đã render sẵn:
Elysia response: <article class="post-card">...</article>Bạn phải hack TanStack Query để treat HTML string như “data”:
// Đây là anti-pattern
const { data: htmlString } = useQuery({
queryKey: ['posts', page],
queryFn: async () => {
const res = await fetch('/fragments/posts?page=' + page)
return res.text() // ← trả về HTML string, không phải JSON
}
})
// Rồi inject thủ công vào DOM
return <div dangerouslySetInnerHTML={{ __html: htmlString }} />Bạn vừa dùng React chỉ để render một HTML string. Bạn vừa mang toàn bộ React runtime — Virtual DOM, reconciliation, fiber architecture — chỉ để làm innerHTML. HTMX làm điều tương tự trong 14KB.
Vấn đề 2: TanStack Router conflict trực tiếp với HTMX hx-push-url
HTMX có hx-push-url để cập nhật URL sau khi swap fragment — đây là cách HDA handle routing:
<a hx-get="/posts/hello-world"
hx-target="#main-content"
hx-push-url="/posts/hello-world">
Đọc bài
</a>TanStack Router cũng muốn quản lý URL. Hai bên đều intercept popstate, pushState, replaceState. Kết quả:
User click link
↓
HTMX intercept → fetch fragment → push URL
↓
TanStack Router detect URL change → trigger route loader → render React component
↓
Hai DOM tree conflict → undefined behaviorKhông có cách nào để hai thứ này coexist mà không có massive workaround. Bạn phải chọn một — và nếu chọn HTMX, TanStack Router là dead weight.
Vấn đề 3: Double caching layer không coherent
Integration giữa TanStack Query và TanStack Router là nơi magic thực sự xảy ra. Prefetching data, caching results, và streaming updates đều seamless, intuitive, và built to scale. TanStack
Magic này hoạt động vì TanStack Router biết route nào cần data nào và prefetch trước khi user navigate. Trong HDA, server đã làm điều này — HTML fragment chứa đúng data server quyết định cho route đó.
TanStack Router + Query cache:
[Route /posts] → loader → queryClient.prefetch(['posts']) → cache JSON → render
HDA cache:
[GET /fragments/posts] → Elysia → KV cache → HTML fragmentHai caching layer hoạt động theo logic hoàn toàn khác nhau, cache những thứ khác nhau, invalidate theo trigger khác nhau. Chạy song song chúng là tăng complexity mà không tăng capability.
Phần 4: Workbox giải quyết đúng bài toán của HDA
Workbox hoạt động ở HTTP layer — không phải JavaScript layer
Đây là sự khác biệt kiến trúc căn bản nhất:
TanStack Query: App Code → useQuery() → fetch() → Network
↑
JS heap cache
Workbox: App Code → fetch() → Service Worker → Network
↑
Cache Storage API
(persistent, cross-tab, offline-capable)Workbox interceptor nằm dưới application code. Nó không quan tâm bạn dùng HTMX, Alpine, Vanilla JS, hay jQuery. Bất kỳ HTTP request nào — kể cả request từ HTMX hx-get — đều đi qua Service Worker.
// workbox-config.js
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies'
// Cache HTML fragments từ Elysia — HTMX tự động được covered
registerRoute(
({ url }) => url.pathname.startsWith('/fragments/'),
new StaleWhileRevalidate({
cacheName: 'html-fragments',
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 300 })
]
})
)
// Cache static assets
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({ cacheName: 'images' })
)HTMX không cần biết Workbox tồn tại. Workbox không cần biết HTMX tồn tại. Hai thứ không conflict vì chúng hoạt động ở tầng khác nhau của network stack.
Workbox + HTMX = offline HDA
Đây là capability mà TanStack không thể cung cấp theo cách tương đương:
User online:
HTMX GET /fragments/posts
→ Service Worker intercept
→ Check Cache Storage
→ Cache miss → Network → Elysia Worker → HTML
→ Cache response → Return HTML → HTMX swap DOM
User mất mạng:
HTMX GET /fragments/posts
→ Service Worker intercept
→ Check Cache Storage
→ Cache hit → Return cached HTML → HTMX swap DOM
→ User không biết gì đã xảy raTanStack Query có offline support — nhưng chỉ cho JSON data trong memory. Khi tab đóng lại, cache mất. Workbox dùng Cache Storage API — persistent trên disk, survive tab close, survive browser restart.
Stale-While-Revalidate là chiến lược hoàn hảo cho HDA + WordPress
┌─────────────────────────────────┐
Request đến │ Service Worker │
────────────► │ │
│ 1. Trả cached HTML NGAY LẬP TỨC│──► HTMX nhận HTML
│ (user thấy content ~0ms) │ swap vào DOM
│ │
│ 2. Đồng thời fetch fresh từ │
│ Elysia Worker (background) │
│ │
│ 3. Update cache với response │
│ mới → sẵn cho request sau │
└─────────────────────────────────┘WordPress API chậm 2–3 giây? User không quan tâm — họ thấy content cached ngay lập tức. Background fetch chạy thầm lặng, cập nhật cache cho lần sau.
TanStack Query cũng có staleTime — nhưng nó refetch vào JS heap, và nếu component unmount trước khi refetch xong, data bị discard.
Phần 5: Ma trận so sánh thẳng thắn
| Tiêu chí | Workbox | TanStack Router / Query |
|---|---|---|
| Tầng hoạt động | Network (Service Worker) | JavaScript (App code) |
| Tương thích HTMX | Transparent | Conflict trực tiếp |
| Định dạng cache | HTML, CSS, JS, JSON | Chỉ JSON |
| Cache bền vững | Có (Cache Storage) | Không (JS heap) |
| Hỗ trợ offline | Native | Hạn chế (chỉ trong bộ nhớ) |
| Yêu cầu React | Không | Có |
| Kích thước bundle | ~6KB (workbox-sw) | ~45KB (router + query) |
| Cô lập giữa tab | Không (chia sẻ Service Worker) | Có (bộ nhớ riêng mỗi tab) |
| Phụ thuộc framework | Không | Chỉ trong hệ sinh thái React |
Phần 6: Khi nào TanStack Router/Query là lựa chọn đúng
Phân tích công bằng yêu cầu thừa nhận điều này. TanStack Start không chỉ giữ cho SPA viable — nó làm chúng tốt hơn. Với simplified patterns, powerful state management, và deep integrations, bạn có thể build SPAs performant hơn, dễ maintain hơn. TanStack
Nếu dự án của bạn:
- Dùng React làm rendering layer chính
- Cần client-side navigation phức tạp với type-safe params
- Fetch JSON và cần intelligent caching + background refetch
- Có team đã quen React ecosystem
- Build dashboard/app phức tạp với nhiều interactive state
→ TanStack Router + Query là lựa chọn tốt hơn Workbox.
Nhưng nếu dự án của bạn là HDA với HTMX + Alpine + Elysia, thì đây là reality check:
Để dùng TanStack trong stack này, bạn buộc phải chấp nhận một loạt đánh đổi:
Trước hết, bạn phải thêm React (hoặc Solid) runtime, kéo theo tối thiểu khoảng 45KB bundle chỉ để hỗ trợ TanStack. Điều này đi ngược lại mục tiêu giữ stack nhẹ khi dùng HTMX.
Tiếp theo, toàn bộ HTMX request phải được bọc trong useQuery. Khi đó, cách viết declarative vốn là điểm mạnh của HTMX gần như biến mất, thay bằng logic điều khiển trạng thái từ JavaScript.
Ngoài ra, bạn phải lựa chọn giữa hx-push-url và TanStack Router. Hai cơ chế routing này xung đột trực tiếp, nên dùng cái này đồng nghĩa với việc phải tắt cái kia.
Để TanStack Query hoạt động với response HTML, bạn còn phải tự xây dựng queryFn trả về HTML string. Đây là một anti-pattern rõ ràng, vì TanStack Query được thiết kế cho dữ liệu, không phải markup.
Cuối cùng, bạn phải quản lý hai lớp cache tách biệt: cache của HTMX và cache của TanStack Query. Chúng không đồng bộ với nhau, làm tăng độ phức tạp mà không mang lại giá trị tương xứng.
Kết quả là bạn tạo ra một SPA “giả” HDA: gánh toàn bộ độ phức tạp của cả hai hướng tiếp cận, nhưng lại không tận dụng được lợi ích cốt lõi của bên nào.
Đây là câu hỏi về coherence, không phải chất lượng
TanStack Router/Query không phải kém hơn Workbox. Chúng là những công cụ xuất sắc cho paradigm của chúng.
Vấn đề là: mỗi tool mang theo assumptions về cách web app hoạt động. TanStack assume client là router, client là cache manager, client là state owner. Workbox assume HTTP là giao thức cần được tăng cường, network requests là đơn vị cần được cache, browser là môi trường cần được made offline-capable.
HTMX + HDA align với Workbox vì cả hai cùng tôn trọng HTTP như là giao thức cốt lõi. TanStack align với React SPA vì cả hai cùng coi JavaScript client là trung tâm điều phối.
Chọn Workbox không phải vì nó tốt hơn TanStack. Chọn Workbox vì nó là mảnh ghép đúng trong bức tranh này — và trong kiến trúc phần mềm, coherence quan trọng hơn việc dùng tool “tốt nhất” theo từng danh mục.
Kết luận
Hypermedia-Driven Applications (HDA) — kết hợp HTMX + Elysia.js + Alpine.js + Workbox — là một chiến lược thực dụng, hiệu quả cho phần lớn ứng dụng web thống trị bởi CRUD, nội dung và e‑commerce. Thay vì ép tất cả logic về phía client bằng SPA nặng nề, HDA giữ HTML làm nguồn thực thi chính, giao tiếp bằng HTTP/HTML và tận dụng edge (Bun/Elysia, Cloudflare Workers) để tối ưu TTFB, SEO và bảo trì.
Những điểm then chốt:
- Hiệu suất & SEO: Server trả HTML semantic trực tiếp → tải nhanh hơn, index tốt hơn, không cần hydration phức tạp.
- Locality of behavior: Hành vi được khai báo ngay trong HTML (htmx, Alpine) → giảm độ phức tạp khi debug và maintain.
- Loose coupling: Server là source of truth cho presentation → thay đổi UI ở server không break client.
- Edge + AoT: Elysia trên Bun/Workers cho latency thấp, AoT compile giảm overhead route/validation.
- Offline & cache: Workbox (Service Worker) cung cấp stale‑while‑revalidate, cache persistent cho HTML fragments → UX mượt ngay cả khi network kém.
- Thực tế của headless WP: WordPress làm content, Elysia làm BFF để transform JSON → HTML fragment; cần giải quyết caching, preview/auth, và API latency bằng chiến lược cache/invalidations (KV + webhooks, SWR).
Hạn chế và khi không nên dùng HDA:
- Không phù hợp cho real‑time collaborative apps, offline‑first full write apps, hoặc heavy client computation (games, DSP, complex viz).
- Dự án headless WordPress cần đầu tư thêm: templating trên server (ví dụ @kitajs/html), cache invalidation phức tạp (WP cache, KV, CDN, SW), preview/auth bridging, và xử lý Gutenberg styles. Expect ~30–50% thời gian dev tăng so với giải pháp “1 hệ thống”.
So sánh công cụ quan trọng:
- Workbox vs TanStack Router/Query: Workbox hoạt ở tầng HTTP (Service Worker) phù hợp với HDA/HTMX (cache HTML fragments, offline, persistent). TanStack phù hợp SPA/React (JSON, client state, routing). Kết hợp TanStack vào HDA thường gây xung đột (routing, duplicate caching) và làm mất mục tiêu giữ stack nhẹ.
- Elysia + Bun + Cloudflare Workers: tối ưu cho edge, AoT compile, type‑safe, dễ deploy, nhưng cần xử lý giới hạn runtime (no fs, CPU/timeouts) và thiết kế stale‑while‑revalidate để che latency WP.
Lời khuyên triển khai (ngắn gọn, action‑oriented):
- Dùng HTMX để fetch HTML fragments; khai báo hành vi ngay trên element (locality of behavior).
- Render fragment server‑side trong Elysia bằng một template strategy không runtime-heavy (ví dụ @kitajs/html).
- Dùng Cloudflare KV + stale‑while‑revalidate pattern để trả cached HTML ngay, refresh ở background; dùng WP webhooks để invalidate cache khi publish/update.
- Dùng Workbox Service Worker để cache HTML fragments và assets (stale‑while‑revalidate, CacheFirst cho images).
- Giải quyết Alpine + HTMX bằng reinit Alpine sau htmx:afterSwap (Alpine.initTree(event.detail.target)).
- Xử lý preview/auth: forward WP preview nonce / app‑level JWT từ Elysia (server‑to‑server) và giữ flow bảo mật.
- Nếu app thực sự cần nhiều client logic/state và routing phức tạp → cân nhắc SPA + TanStack; nếu ưu tiên SEO, performance, đơn giản vận hành → chọn HDA.







