Rendering Strategies and React Server Components: Where HTML Gets Built
CSR, SSR, SSG, streaming, islands, and RSC — a principal-level map of where HTML is generated, what hydration costs, and how to choose.
Every frontend architecture decision eventually collapses to one question: where and when does HTML get generated? {Mọi quyết định kiến trúc frontend cuối cùng đều thu gọn về một câu hỏi: HTML được tạo ở đâu và khi nào?} The answer is not binary — it is a spectrum from fully client-rendered SPAs to fully server-rendered documents, with hybrid models (SSG, ISR, streaming, islands, RSC) occupying the middle ground. {Câu trả lời không phải nhị phân — đó là một phổ từ SPA render hoàn toàn trên client đến document render hoàn toàn trên server, với các mô hình lai (SSG, ISR, streaming, islands, RSC) nằm ở giữa.}
This post is written for senior and principal engineers who already know React. {Bài viết này dành cho senior và principal engineer đã quen React.} We will not rehash Virtual DOM internals or compare framework marketing pages. {Chúng ta sẽ không lặp lại nội bộ Virtual DOM hay so sánh trang marketing của framework.} Instead, we map rendering strategies to user-perceived latency, operational cost, and failure modes — then place React Server Components (RSC) in that landscape as of 2025–2026. {Thay vào đó, ta ánh xạ chiến lược render với độ trễ người dùng cảm nhận, chi phí vận hành, và failure mode — rồi đặt React Server Components (RSC) vào bối cảnh đó tính đến 2025–2026.}
Scope note: Examples use React/Next.js idioms because RSC is a React feature, but the trade-offs apply to any stack. {Ghi chú phạm vi: Ví dụ dùng idiom React/Next.js vì RSC là tính năng React, nhưng trade-off áp dụng cho mọi stack.}
The rendering spectrum
At the highest level, rendering strategies differ along three axes:
{Ở mức cao nhất, chiến lược render khác nhau theo ba trục:}
| Axis | Client-heavy | Server-heavy |
|---|---|---|
| When HTML is produced | After JS downloads and executes | Before or during the HTTP response |
| Where computation runs | User’s device (browser) | Origin server, edge, or build pipeline |
| What ships to the client | JS bundle + data fetches | HTML (+ optional JS for interactivity) |
The “best” strategy is never universal — it depends on content freshness, interactivity density, auth boundaries, and infra budget. {Chiến lược “tốt nhất” không bao giờ phổ quát — phụ thuộc độ tươi của content, mật độ tương tác, ranh giới auth, và ngân sách hạ tầng.}
Fully client ◄────────────────────────────────────────► Fully server
CSR SSR (blocking) Streaming SSR SSG/ISR RSC + islands
│ │ │ │ │
│ │ │ │ └─ Server components + selective client JS
│ │ │ └─ Pre-built HTML, optional revalidation
│ │ └─ Chunked HTML as data resolves
│ └─ Full HTML per request, then hydrate
└─ Empty shell → fetch → render in browser
Client-Side Rendering (CSR)
In CSR, the server sends a minimal HTML shell (often just <div id="root"></div>) and a JavaScript bundle. {Trong CSR, server gửi HTML shell tối thiểu (thường chỉ <div id="root"></div>) và bundle JavaScript.} The browser downloads, parses, and executes JS, which fetches data and constructs the DOM. {Browser tải, parse, và thực thi JS, rồi fetch data và dựng DOM.}
Why CSR dominated for a decade
CSR simplified deployment: static files on a CDN, no server compute per page view. {CSR đơn giản hóa deploy: file tĩnh trên CDN, không cần compute server theo page view.} It paired naturally with REST/GraphQL APIs and gave teams a clean separation between frontend and backend repos. {Nó khớp tự nhiên với REST/GraphQL API và tách repo frontend/backend rõ ràng.} For authenticated dashboards where SEO is irrelevant, CSR still works. {Với dashboard có auth mà SEO không quan trọng, CSR vẫn ổn.}
The blank-screen problem
The user sees nothing useful until:
{Người dùng không thấy gì hữu ích cho đến khi:}
- HTML document arrives {HTML document tới}
- JS bundle downloads (often 200KB–2MB+ gzipped for React apps) {JS bundle tải xong (thường 200KB–2MB+ gzip cho app React)}
- JS parses and executes {JS parse và chạy}
- Data fetches complete (waterfall if nested) {Fetch data xong (waterfall nếu lồng nhau)}
- React commits to the DOM {React commit lên DOM}
On slow networks or low-end devices, steps 2–4 dominate Time to Interactive (TTI). {Trên mạng chậm hoặc thiết bị yếu, bước 2–4 chi phối Time to Interactive (TTI).} Lighthouse may show acceptable First Contentful Paint if a loading spinner renders early — but the spinner is not content. {Lighthouse có thể cho FCP chấp nhận được nếu spinner render sớm — nhưng spinner không phải content.}
CSR failure modes
| Failure mode | Root cause |
|---|---|
| SEO gaps | Crawlers that don’t execute JS miss content |
| Layout shift after hydration | Client-only measurement changes layout |
| Bundle bloat | Every route’s dependencies ship upfront or via lazy chunks with latency cost |
| Request waterfalls | Component tree triggers sequential fetches after mount |
When CSR is still right: Internal tools, real-time trading UIs, collaborative editors, or apps behind auth where TTFB matters less than rich client state. {Khi CSR vẫn đúng: Tool nội bộ, UI trading real-time, editor cộng tác, hoặc app sau auth mà TTFB ít quan trọng hơn state client phong phú.}
Server-Side Rendering (SSR)
SSR generates HTML on the server per request (or per request with caching layers). {SSR tạo HTML trên server theo request (hoặc theo request có lớp cache).} The browser receives meaningful markup immediately — better Time to First Byte (TTFB)-adjacent metrics like First Contentful Paint (FCP) and Largest Contentful Paint (LCP). {Browser nhận markup có nghĩa ngay — metric gần Time to First Byte (TTFB) như FCP và LCP tốt hơn.}
// Conceptual SSR flow (framework handles the wiring)
async function renderPage(url: string): Promise<string> {
const data = await fetchProduct(url);
const html = ReactDOMServer.renderToString(<ProductPage data={data} />);
return wrapInDocument(html);
}
TTFB vs interactivity
SSR improves first paint but does not eliminate the client JS requirement for React apps. {SSR cải thiện first paint nhưng không loại bỏ yêu cầu JS client cho app React.} The same bundle must still download and hydrate — attaching event listeners and reconciling server HTML with client React. {Cùng bundle vẫn phải tải và hydrate — gắn event listener và reconcile HTML server với React client.}
You can have fast LCP and slow TTI simultaneously. {Bạn có thể có LCP nhanh và TTI chậm cùng lúc.} Users see content but buttons don’t respond until hydration completes. {Người dùng thấy content nhưng nút chưa phản hồi cho đến khi hydration xong.}
The hydration uncanny valley
Between HTML arrival and hydration finish, the page looks interactive but isn’t. {Giữa lúc HTML tới và hydration xong, trang trông tương tác được nhưng thực tế không.} Clicks on unhydrated regions may no-op or queue unpredictably. {Click vùng chưa hydrate có thể không làm gì hoặc xếp hàng không dự đoán được.} Form inputs may reset when React takes over if markup differs. {Input form có thể reset khi React tiếp quản nếu markup khác.}
This is the uncanny valley of hydration: visually complete, behaviorally incomplete. {Đây là uncanny valley của hydration: nhìn hoàn chỉnh, hành vi chưa đủ.}
Hydration cost
Hydration is not free {Hydration không miễn phí}. The client must:
{Client phải:}
- Re-run component logic (often duplicating server work) {Chạy lại logic component (thường lặp công việc server)}
- Walk the entire tree (or large subtrees) to attach fibers {Duyệt cả cây (hoặc subtree lớn) để gắn fiber}
- Serialize/deserialize props embedded in HTML (
__NEXT_DATA__,window.__INITIAL_STATE__, etc.) {Serialize/deserialize props nhúng trong HTML}
For content-heavy pages with small interactive islands (a nav toggle, a cart badge), hydrating the full page is wasteful. {Với trang nhiều content và island tương tác nhỏ (nav toggle, cart badge), hydrate cả trang là lãng phí.}
Blocking SSR and the data waterfall
Classic blocking SSR waits for all data before sending HTML:
{SSR blocking cổ điển chờ mọi data trước khi gửi HTML:}
Request ──► fetch layout data ──► fetch page data ──► fetch sidebar data ──► HTML
─────────────────── TTFB grows linearly ───────────────────────►
If any upstream call is slow, the entire response stalls. {Nếu call upstream chậm, cả response bị chặn.} This is the request waterfall on the server — structurally identical to client waterfalls, just shifted earlier. {Đây là request waterfall trên server — cấu trúc giống waterfall client, chỉ dời sớm hơn.}
Static Site Generation (SSG) and Incremental Static Regeneration (ISR)
SSG moves HTML generation to build time. {SSG dời tạo HTML sang build time.} Each route becomes a static file served from CDN — TTFB approaches network latency, not server compute. {Mỗi route thành file tĩnh trên CDN — TTFB gần với độ trễ mạng, không phải compute server.}
# Build-time: generate /blog/post-1.html, /blog/post-2.html, ...
npm run build
# Deploy: CDN serves pre-rendered HTML globally
When SSG fits
- Marketing pages, docs, blogs {Trang marketing, docs, blog}
- Catalogs that change on deploy cadence, not per second {Catalog đổi theo nhịp deploy, không theo giây}
- Content addressable at build time (known slug list) {Content biết slug lúc build}
ISR: static speed with eventual freshness
ISR (popularized by Next.js revalidate) keeps static delivery but regenerates pages in the background after a TTL or on-demand. {ISR (phổ biến qua Next.js revalidate) giữ delivery tĩnh nhưng regenerate trang nền sau TTL hoặc on-demand.}
| Trigger | Behavior |
|---|---|
Time-based (revalidate: 60) | Serve stale HTML; rebuild after 60s on next request |
| On-demand (webhook/tag) | Invalidate specific paths when CMS publishes |
| First request after expiry | User may get stale page while rebuild runs (stale-while-revalidate) |
Cache invalidation is the hard part
ISR shifts complexity from runtime rendering to cache coherence. {ISR chuyển phức tạp từ runtime render sang cache coherence.} Principal engineers should ask:
{Principal engineer nên hỏi:}
- What is the maximum staleness users tolerate? {Độ stale tối đa người dùng chấp nhận?}
- Do personalized segments break static caching? {Segment cá nhân hóa có phá cache tĩnh?}
- How do you invalidate related paths (index + detail + RSS)? {Invalidate path liên quan (index + detail + RSS) thế nào?}
Pitfall: Treating ISR as “free SSR.” Regeneration still hits origin, database, and CMS APIs — at scale, spikes follow traffic patterns. {Pitfall: Coi ISR như “SSR miễn phí.” Regenerate vẫn đánh origin, database, CMS API — ở scale, spike theo pattern traffic.}
Streaming SSR
Streaming SSR sends HTML in chunks as segments become ready, using HTTP chunked transfer encoding. {Streaming SSR gửi HTML theo chunk khi từng phần sẵn sàng, dùng HTTP chunked transfer encoding.} React 18’s renderToPipeableStream (Node) and similar APIs in other runtimes enable this. {renderToPipeableStream của React 18 (Node) và API tương tự runtime khác cho phép điều này.}
Suspense boundaries as backpressure valves
Wrap slow subtrees in Suspense with a fallback; the server emits the fallback first, then streams the resolved UI. {Bọc subtree chậm trong Suspense với fallback; server emit fallback trước, rồi stream UI đã resolve.}
export default function Page() {
return (
<main>
<Header /> {/* sends immediately */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* streams when DB query completes */}
</Suspense>
</main>
);
}
The browser can paint the shell and above-the-fold content while comments still load. {Browser có thể paint shell và above-the-fold trong khi comments vẫn đang load.} TTFB improves because the first byte leaves before slow IO finishes. {TTFB tốt hơn vì byte đầu rời đi trước khi IO chậm xong.}
Progressive and selective hydration
Streaming pairs naturally with progressive hydration: hydrate interactive regions in priority order (viewport first, idle later). {Streaming đi cùng progressive hydration: hydrate vùng tương tác theo thứ tự ưu tiên (viewport trước, idle sau).} Frameworks like Marko, Qwik, and Astro (with client directives) push further toward partial/selective hydration — only islands that need client JS pay the cost. {Framework như Marko, Qwik, và Astro (với client directive) đi xa hơn về partial/selective hydration — chỉ island cần JS client trả chi phí.}
Streaming failure modes
| Issue | What goes wrong |
|---|---|
| Suspense misplacement | Entire page blocked if boundary wraps too much |
| Error during stream | Partial HTML sent; error boundaries must handle late failures |
| Duplicate data fetching | Server component + client child both fetch same resource without dedup |
| CDN buffering | Some proxies buffer full response, defeating streaming |
Islands architecture and resumability
Islands architecture (Astro, early Fresh, Elder.js) inverts the default: HTML is static by default; interactive widgets opt in via directives (client:load, client:visible, etc.). {Islands architecture (Astro, Fresh sớm, Elder.js) đảo mặc định: HTML tĩnh mặc định; widget tương tác opt-in qua directive.}
---
// Astro: server-rendered markdown + one interactive island
import Counter from '../components/Counter.jsx';
---
<article>{/* static HTML, zero JS */}</article>
<Counter client:visible />
Each island hydrates independently. {Mỗi island hydrate độc lập.} The rest of the page ships zero client JS. {Phần còn lại trang ship không JS client.}
Hydration vs resumability (Qwik)
Traditional hydration re-executes component code on the client to rebuild state. {Hydration cổ điển chạy lại code component trên client để dựng lại state.} Resumability (Qwik’s model) serializes closure state into HTML attributes during SSR, then “resumes” listeners without re-running the full tree. {Resumability (mô hình Qwik) serialize state closure vào attribute HTML lúc SSR, rồi “resume” listener mà không chạy lại cả cây.}
| Model | Client work on load |
|---|---|
| Full hydration | Re-run components, rebuild virtual tree, attach events |
| Partial hydration | Re-run subset of components |
| Resumability | Deserialize state, lazy-load handlers on interaction |
Neither model eliminates JS for interactivity — they change when and how much JS runs before the page feels alive. {Không mô hình nào loại JS cho tương tác — chúng đổi khi nào và bao nhiêu JS chạy trước khi trang sống.}
Principal insight: Islands and RSC share a goal — minimize client JavaScript — but differ in component model. Islands split by framework boundary; RSC splits by server/client component boundary within React. {Insight principal: Islands và RSC cùng mục tiêu — giảm JavaScript client — nhưng khác component model. Islands tách theo ranh giới framework; RSC tách theo ranh giới server/client component trong React.}
React Server Components (RSC)
React Server Components (stable in React 19, adopted across Next.js App Router, Waku, and experimental setups elsewhere) introduce a new component type that runs only on the server. {React Server Components (ổn định trong React 19, áp dụng qua Next.js App Router, Waku, và setup thử nghiệm khác) giới thiệu loại component mới chỉ chạy trên server.} They never hydrate. {Chúng không bao giờ hydrate.} They do not ship JavaScript to the client as component code. {Không ship JavaScript lên client dưới dạng code component.}
As of 2025–2026, RSC is production-viable but opinionated: you need a bundler, server runtime, and flight protocol integration — it is not a drop-in for Create React App. {Tính đến 2025–2026, RSC production-ready nhưng có quan điểm: cần bundler, server runtime, và tích hợp flight protocol — không drop-in cho Create React App.}
Server vs client components
By default, components in an RSC-enabled app are Server Components. {Mặc định, component trong app bật RSC là Server Component.} Add 'use client' at the top of a file to mark a Client Component boundary. {Thêm 'use client' đầu file để đánh dấu ranh giới Client Component.}
// app/comments.tsx — Server Component (no directive)
import { db } from '@/lib/db';
export async function Comments({ postId }: { postId: string }) {
const rows = await db.comment.findMany({ where: { postId } });
return (
<ul>
{rows.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
);
}
// app/like-button.tsx — Client Component
'use client';
import { useState } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked((v) => !v)}>
{liked ? 'Unlike' : 'Like'}
</button>
);
}
Rules that senior engineers internalize:
{Quy tắc senior engineer nên nội hóa:}
| Server Components | Client Components |
|---|---|
Can async/await directly | Cannot be async (as component function) |
| Can access DB, filesystem, secrets | Cannot access server-only modules |
No useState, useEffect, browser APIs | Full hooks and event handlers |
| Cannot import Client Components directly into server-only logic without composition | Can import Server Components as children/slots |
Composition pattern: Server Component renders Client Component, passing serializable props and server-rendered children. {Pattern composition: Server Component render Client Component, truyền props serializable và children render trên server.}
// page.tsx — Server Component
import { LikeButton } from './like-button';
import { Comments } from './comments';
export default async function PostPage({ params }: { params: { id: string } }) {
return (
<article>
<LikeButton postId={params.id} />
<Comments postId={params.id} />
</article>
);
}
The RSC payload and serialization
When the server renders, it produces:
{Khi server render, nó tạo:}
- HTML for initial paint (SSR still applies) {HTML cho initial paint (SSR vẫn áp dụng)}
- RSC Payload (Flight) — a compact, line-oriented format describing the component tree, props, and lazy references {RSC Payload (Flight) — định dạng gọn, theo dòng mô tả cây component, props, và lazy reference}
The client runtime reconciles Flight data to update the tree on navigation without full page reloads. {Client runtime reconcile dữ liệu Flight để cập nhật cây khi navigate mà không reload full page.}
Serializable props only: functions, class instances, and Symbols cannot cross the boundary. {Chỉ props serializable: function, class instance, và Symbol không qua ranh giới được.} Dates and Maps require explicit serialization conventions. {Date và Map cần quy ước serialize rõ.}
// ❌ Cannot pass a callback from Server to Client like this:
<ClientForm onSubmit={(data) => save(data)} />
// ✅ Use Server Actions or URL-based mutations instead
This constraint surprises teams migrating from traditional SSR where inline handlers were accidentally bundled. {Ràng buộc này gây bất ngờ team migrate từ SSR cổ điển từng bundle nhầm inline handler.}
Server Actions
Server Actions ('use server') are async functions invoked from Client Components via POST, executing on the server with CSRF protections (framework-dependent). {Server Actions ('use server') là async function gọi từ Client Component qua POST, chạy trên server với bảo vệ CSRF (tùy framework).}
// actions.ts
'use server';
export async function addComment(postId: string, formData: FormData) {
const body = formData.get('body');
await db.comment.create({ data: { postId, body: String(body) } });
}
'use client';
import { addComment } from './actions';
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={(fd) => addComment(postId, fd)}>
<textarea name="body" />
<button type="submit">Post</button>
</form>
);
}
Server Actions colocate mutations with UI — reducing bespoke API route boilerplate — but they are not a replacement for every backend endpoint (webhooks, third-party integrations, mobile clients still need REST/GraphQL). {Server Actions colocate mutation với UI — giảm boilerplate API route — nhưng không thay mọi backend endpoint (webhook, tích hợp bên thứ ba, mobile client vẫn cần REST/GraphQL).}
Colocated data fetching
RSC’s killer ergonomic win: fetch where you render, on the server, without exposing endpoints. {Lợi ergonomic lớn của RSC: fetch nơi render, trên server, không lộ endpoint.}
async function ProductDetails({ id }: { id: string }) {
const product = await getProduct(id); // direct DB or internal API
return <h1>{product.name}</h1>;
}
Parallel fetching happens naturally when sibling Server Components are independent — the runtime can issue concurrent IO. {Fetch song song tự nhiên khi Server Component anh em độc lập — runtime có thể IO đồng thời.} Contrast with client useEffect chains. {Khác với chuỗi useEffect trên client.}
Caution: Without explicit caching/dedup (cache(), React.cache, fetch next: \{ revalidate \} in Next.js), duplicate requests still occur across components. {Cẩn thận: Không cache/dedup rõ (cache(), React.cache, fetch next: \{ revalidate \} trong Next.js), request trùng vẫn xảy ra giữa component.}
What RSC does not solve
- Client interactivity still requires Client Components and JS bundles. {Tương tác client vẫn cần Client Component và bundle JS.}
- Layout shift if streamed fallbacks are poorly designed. {Layout shift nếu fallback stream thiết kế kém.}
- Operational complexity: server render farms, flight protocol versioning, debugging split stacks. {Phức tạp vận hành: cụm server render, versioning flight protocol, debug stack tách.}
- Vendor coupling risk in 2025–2026: deepest tooling remains Next.js-centric; alternative RSC runtimes exist but ecosystem maturity varies. {Rủi ro phụ thuộc vendor 2025–2026: tooling sâu nhất vẫn xoay quanh Next.js; runtime RSC khác có nhưng độ trưởng thành ecosystem khác nhau.}
Decision matrix: app type → strategy
Use this as a starting point for architecture reviews, not a prescription. {Dùng làm điểm xuất phát cho architecture review, không phải đơn thuốc.}
| App profile | Primary strategy | Secondary / hybrid |
|---|---|---|
| Marketing site, docs, blog | SSG (+ ISR for CMS) | Islands for search, theme toggle |
| E-commerce catalog | ISR / SSG with on-demand invalidation | Client cart/checkout islands |
| Authenticated dashboard | CSR or RSC + Client islands | SSR for first paint if SEO/login matters |
| Social feed, real-time | CSR with SSE/WebSocket | Streaming SSR for shell |
| Content + rich interactivity (e.g. docs with playground) | SSG + partial hydration | RSC for dynamic auth-gated sections |
| Multi-tenant SaaS marketing + app | SSG marketing, CSR/RSC app subdomain | Shared design system, separate deploy |
Questions to ask in every review
- What must Google see without executing JS? → pushes toward SSR/SSG/RSC server render {Google phải thấy gì không cần chạy JS? → hướng SSR/SSG/RSC server render}
- What is the p95 TTI budget on mid-tier Android? → pushes toward less client JS {Ngân sách p95 TTI trên Android tầm trung? → ít JS client hơn}
- How fresh must data be? → SSG vs ISR vs SSR {Data phải tươi đến mức nào? → SSG vs ISR vs SSR}
- Where do secrets live? → server components/actions vs public API {Secret ở đâu? → server component/action vs API public}
- Who operates the server at scale? → CSR/CDN-only vs render compute {Ai vận hành server ở scale? → CSR/CDN-only vs compute render}
Common pitfalls and failure modes
The 'use client' sprawl
Teams under deadline mark entire directories 'use client' because one hook was needed. {Team gấp deadline đánh 'use client' cả thư mục vì cần một hook.} That collapses the RSC boundary — all children ship as client bundles. {Ranh giới RSC sụp — mọi con ship thành client bundle.}
Fix: Push 'use client' to leaf components; keep data-fetching parents as Server Components. {Sửa: Đẩy 'use client' xuống leaf; giữ parent fetch data là Server Component.}
❌ 'use client' on page.tsx (entire route hydrates)
✅ page.tsx (server) → ChartWrapper (server) → Chart (client)
Accidental waterfalls
Even with RSC, sequential await in one Server Component recreates blocking SSR. {Dù có RSC, await tuần tự trong một Server Component tái tạo SSR blocking.}
// ❌ Sequential — latency sums
async function Page() {
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
return <Feed user={user} posts={posts} comments={comments} />;
}
// ✅ Parallelize independent IO
async function Page() {
const [user, trending] = await Promise.all([getUser(), getTrending()]);
return (
<>
<UserHeader user={user} />
<Suspense fallback={<Skeleton />}>
<PostList userId={user.id} />
</Suspense>
<TrendingSidebar items={trending} />
</>
);
}
Over-fetching in Client Components
Moving data fetching to useEffect in Client Components after adopting RSC throws away the main benefit. {Chuyển fetch sang useEffect trong Client Component sau khi adopt RSC vứt bỏ lợi ích chính.} Keep fetches in Server Components; pass results down. {Giữ fetch ở Server Component; truyền kết quả xuống.}
Ignoring caching semantics
RSC does not magically cache. {RSC không tự cache.} Explicit cache(), unstable_cache, fetch revalidation, or tag-based invalidation must match product staleness requirements. {cache(), unstable_cache, fetch revalidation, hoặc invalidate theo tag phải khớp yêu cầu staleness sản phẩm.} Default “dynamic everywhere” recreates SSR cost without CDN benefit. {Mặc định “dynamic mọi nơi” tái tạo chi phí SSR không lợi CDN.}
Testing and observability gaps
Server Components break assumptions of @testing-library/react in jsdom — you test Client leaves and integration-test server paths separately. {Server Component phá giả định @testing-library/react trong jsdom — test leaf Client và integration-test path server riêng.} Distributed traces must span server render, Flight serialization, and client navigation. {Trace phân tán phải span server render, serialize Flight, và client navigation.}
A principal-engineer mental model
Think in three budgets that trade against each other:
{Nghĩ theo ba ngân sách cạnh tranh nhau:}
┌─────────────────────────────────────────────────────────┐
│ 1. Bytes to client (JS + RSC payload + assets) │
│ 2. Server compute per request (SSR, RSC, actions) │
│ 3. Staleness / consistency (CDN cache, ISR, realtime) │
└─────────────────────────────────────────────────────────┘
Optimize one → usually costs another
Rendering strategy is boundary drawing. {Chiến lược render là vẽ ranh giới.} You draw boundaries between:
{Bạn vẽ ranh giới giữa:}
- Static vs dynamic content {Content tĩnh vs dynamic}
- Server-only vs client-required logic {Logic chỉ server vs bắt buộc client}
- Cacheable vs personalized segments {Segment cache được vs cá nhân hóa}
- Streaming vs atomic responses {Response stream vs atomic}
RSC adds a fourth axis: serializable vs non-serializable props across the wire. {RSC thêm trục thứ tư: props serializable vs không serializable qua wire.} Every 'use client' file is a conscious tax on budget #1. {Mỗi file 'use client' là thuế có chủ đích lên ngân sách #1.}
The 2025–2026 state of play
| Trend | Reality check |
|---|---|
| RSC as default for new React apps | True for Next.js App Router greenfield; less true elsewhere |
| CSR SPAs “dead” | False for apps prioritizing client state and offline |
| Islands gaining share | True for content sites; limited for complex client state |
| Streaming SSR baseline | Expected for React 18+ meta-frameworks |
| Partial hydration maturing | Selective approaches exist; no single standard yet |
React Server Components are not a rendering strategy alone — they are a component execution model that composes with SSR, streaming, and static generation. {React Server Components không chỉ là chiến lược render — đó là mô hình thực thi component kết hợp với SSR, streaming, và static generation.} The winning architecture in 2026 is hybrid by design: static shells, streamed dynamic regions, client islands for interaction, and server components for data and secrets. {Kiến trúc thắng 2026 là hybrid có chủ đích: shell tĩnh, vùng dynamic stream, client island cho tương tác, và server component cho data và secret.}
Closing principle: Ship the smallest amount of JavaScript required for interactivity; generate everything else as close to the user (CDN/edge) or as early (build time) as staleness allows. {Nguyên tắc kết: Ship lượng JavaScript nhỏ nhất cần cho tương tác; tạo mọi thứ còn lại gần user (CDN/edge) hoặc sớm (build time) trong giới hạn staleness cho phép.}
Measure FCP, LCP, TTI, and Total Blocking Time together — optimizing one in isolation creates the uncanny valley elsewhere. {Đo FCP, LCP, TTI, và Total Blocking Time cùng lúc — tối ưu một metric riêng lẻ tạo uncanny valley chỗ khác.} That is the job at the principal level: not picking CSR or SSR from a blog post checklist, but negotiating budgets across product, infra, and UX with eyes open about hydration cost, cache invalidation, and the RSC serialization boundary. {Đó là việc ở cấp principal: không chọn CSR hay SSR từ checklist blog, mà thương lượng ngân sách giữa product, infra, và UX với mắt mở về chi phí hydration, invalidate cache, và ranh giới serialize RSC.}