Performance, SSR, hydration và Server Components — Next.js 16
Đây là phần mà người làm backend có lợi thế nhất khi phỏng vấn, vì React Server Components và Next.js App Router thực
chất là đưa một phần frontend về server: có request, có cache, có endpoint, có lỗ hổng bảo mật. Bài này đi từ
"vì sao SPA chậm" tới SSR streaming, hydration, Server Components, Server Actions và mô hình cache mới của Next.js 16
('use cache', Partial Prerendering) — mọi hành vi đều chạy thật, kể cả các bẫy bảo mật.
Đã verify gì. SSR/hydration chạy trên React 19.3.0 + react-dom/server, jsdom 26.1. Next.js 16.3.7 (cacheComponents: true) build vànext startthật trong container tạm, gọi bằngfetchtừ Node — không phải browser thật, nên các chỉ số LCP/INP chưa verify. Chạy một instance: hành vi cache khi có nhiều instance chưa verify. Các CVE (React2Shell, bypass middleware) tóm tắt theo advisory công khai, không tái hiện. Lệnh ở phụ lục.
Bảng thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| CSR (client-side rendering) | Server gửi HTML rỗng + JS; browser chạy JS mới vẽ ra trang. |
| SSR (server-side rendering) | Server chạy React ra HTML cho mỗi request, browser hiện được ngay rồi mới tải JS. |
| hydration | React ở browser "nhận lại" HTML do server tạo: gắn event handler, dựng Fiber, không vẽ lại. |
| streaming SSR | Gửi HTML theo từng mẩu: phần xong trước gửi trước, phần chậm gửi sau trong cùng một response. |
| RSC (React Server Components) | Component chỉ chạy trên server, không gửi code xuống browser; kết quả gửi đi dưới dạng dữ liệu (RSC payload). |
| RSC payload / Flight | Định dạng tuần tự hoá (serialize — đổi thành chuỗi gửi qua mạng) cây component từ server xuống client. |
| Client Component | Component có 'use client': code được gửi xuống browser, được hydrate, dùng được state/effect. |
| Server Function / Server Action | Hàm 'use server': client gọi được như hàm thường, thực chất là một request POST tới server. |
| PPR (Partial Prerendering) | Phần tĩnh của trang render sẵn lúc build; các "lỗ" động bọc trong Suspense được stream lúc request. |
| TTFB / LCP | Thời gian tới byte đầu tiên / tới lúc phần nội dung lớn nhất hiện ra (tốt: LCP < 2,5s). |
Câu trả lời 60 giây
"CSR chậm vì một chuỗi thác: tải HTML rỗng → tải JS → chạy JS → mới gọi API → mới vẽ. SSR đưa HTML về sớm, nhưng kiểu cũrenderToStringphải đợi mọi dữ liệu và không chờ được Suspense. React 18 có streaming: gửi khung trang ngay, phần chậm stream sau trong cùng response — lab của tôi: shell tới ở 31ms, phần đánh giá chậm tới ở 827ms thay vì chặn cả trang. Hydration phải khớp HTML server, lệch là React vứt HTML và render lại tới Suspense gần nhất. Server Components không gửi code xuống browser, nhưng ranh giới server→client là ranh giới tuần tự hoá qua mạng: props phải serialize được, và mọi thứ truyền qua đó nằm trong HTML — lab thấypasswordHashlộ ra khi truyền nguyên object user. Server Action là một endpoint POST công khai: lab gọi được action 'xoá user' bằng curl không cần đăng nhập. Nên quy tắc là auth và validate trong từng action, và có một data access layer chỉ trả DTO."
1. Bài toán gốc và bối cảnh ra đời
sequenceDiagram participant B as Browser participant S as Server participant A as API Note over B,A: CSR (SPA) B->>S: GET /product/1 S-->>B: HTML rỗng B->>S: GET app.js (vài trăm KB) Note over B: parse + chạy JS B->>A: GET /api/product/1 A-->>B: JSON Note over B: lúc này mới vẽ được Note over B,A: SSR streaming + RSC B->>S: GET /product/1 S->>A: lấy dữ liệu (cùng mạng nội bộ, nhanh) S-->>B: HTML khung + dữ liệu nhanh (vẽ ngay) S-->>B: mẩu HTML phần chậm (stream sau) B->>S: JS chỉ của Client Component Note over B: hydrate phần tương tác
| Thế hệ | Giải quyết | Còn vướng |
|---|---|---|
| CSR (SPA, 2013–) | Tương tác mượt, deploy như file tĩnh | Trang trắng lâu, SEO kém, chuỗi thác request, bundle to dần |
SSR kiểu cũ (renderToString, Next.js Pages Router 2016) | HTML có nội dung ngay, SEO | Phải đợi mọi dữ liệu mới gửi byte đầu; hydrate toàn trang một lần; vẫn gửi code của mọi component |
| Streaming SSR + selective hydration (React 18, 2022) | Gửi từng phần, hydrate từng phần | Vẫn gửi code mọi component xuống client |
| RSC (công bố 12/2020, ổn định trong React 19; Next.js App Router 2023) | Component không tương tác không tốn JS ở client; lấy dữ liệu ngay trên server | Mô hình phức tạp, ranh giới server/client dễ sai, bề mặt tấn công mới |
| Next.js 16 (10/2025): Cache Components | Cache rõ ràng bằng 'use cache', PPR mặc định khi bật | Phải học lại mô hình cache (lần thứ ba trong 3 năm) |
| SSR + RSC tối ưu cho | KHÔNG tối ưu cho |
|---|---|
| Trang công khai cần SEO và LCP: e-commerce, nội dung, landing | App nội bộ sau đăng nhập, tương tác dày (admin, editor) — SPA đơn giản hơn, SEO không cần |
| Trang nhiều nội dung, ít tương tác (bớt JS) | Team không có người vận hành Node server (SSR cần server chạy liên tục, tốn CPU mỗi request) |
| Lấy dữ liệu gần database | Hệ thống đã có backend mạnh (Laravel) và chỉ cần vài màn tương tác — Inertia/Livewire rẻ hơn |
2. Mô hình tư duy cốt lõi
Ý tưởng 1 — Hai môi trường, một ranh giới tuần tự hoá. Server Component chạy một lần trên server, kết quả là dữ liệu. Client Component chạy ở browser (và cũng chạy trên server để SSR ra HTML). Chỗ Server Component render một Client Component là một lần gửi qua mạng: mọi props bị serialize thành chuỗi.
name, nhưng cả object đã đi qua ranh giới và nằm trong HTML. "Không hiển thị"
khác "không gửi".Ý tưởng 2 — Trang là một dòng chảy theo thời gian: khung tĩnh (prerender lúc build hoặc cache) → phần động stream trong Suspense → JS của Client Component tải và hydrate. Mọi tối ưu hiệu năng là "đẩy càng nhiều thứ lên đầu dòng chảy càng tốt, và bớt thứ phải hydrate".
Hệ quả suy ra từ hai ý tưởng:
- Props qua ranh giới phải serialize được → không truyền được hàm (lab:
Event handlers cannot be passed to Client Component props), trừ Server Action. - Mọi thứ qua ranh giới là công khai → lộ dữ liệu (lab:
passwordHashtrong HTML). - Server Action là hàm "gọi qua mạng" → nó là endpoint công khai (lab: gọi bằng POST không cần đăng nhập).
- Code Server Component không xuống browser → import thư viện nặng (markdown, date) ở server là miễn phí về bundle (lab: chuỗi của Server Component có trong 0 file JS tĩnh, của Client Component có trong 1).
- HTML server phải khớp lần render đầu ở client → mọi thứ phụ thuộc thời gian, random, timezone, localStorage đều gây mismatch.
3. Internals và quyết định thiết kế
3.1 renderToString vs renderToPipeableStream
Lab: trang sản phẩm có header, giá (nhanh) và đánh giá (API chậm 800ms) bọc trong Suspense.
renderToString: 11ms, có "reviews xong"? false, có fallback? true
renderToPipeableStream onShellReady +25ms
+ 31ms chunk 228B: <!DOCTYPE html><html><head></head><body><h1>Sản phẩm</h1><p>Giá 199k</p><!--$?--><template id="B:0"></template…
+ 827ms chunk 920B: <div hidden id="S:0"><section>reviews xong</section></div><script>$RB=[];$RV=function(a){…
+ 827ms chunk 14B: </body></html>
Đọc output:
renderToStringlà đồng bộ: gặp Suspense thì trả luôn fallback, không bao giờ chờ dữ liệu. Muốn có dữ liệu phải lấy hết trước khi gọi → TTFB = thời gian của API chậm nhất.- Streaming gửi shell sau 31ms, trong đó chỗ Suspense là
<template id="B:0">(placeholder). 800ms sau, server gửi tiếp một<div hidden id="S:0">chứa nội dung thật và một script nhỏ đổi chỗ nó vào placeholder. Không cần JS của app để thấy nội dung — chỉ cần script inline.
3.2 Hydration: nhận lại HTML, và khi không khớp thì sao
hydrateRoot đi song song cây Fiber với DOM có sẵn, gắn event và kiểm tra khớp. Lab: component hiện giờ
"10:00" ở server, "17:00" ở client (kiểu new Date() hay timezone khác nhau):
[lệch text, không có Suspense]
màn hình: "17:00khối nặng khác" | thẻ <p> của server còn giữ? false
onRecoverableError: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. ...
[lệch text, bọc trong Suspense]
màn hình: "khối nặng khác17:00" | thẻ <p> của server còn giữ? true
[suppressHydrationWarning]
màn hình: "10:00khối nặng khác" | thẻ <p> của server còn giữ? true
onRecoverableError: (không có)
- Không có Suspense: React vứt toàn bộ HTML server tới root và render lại ở client — thẻ
<p>không liên quan cũng bị tạo lại. Mất hết lợi ích SSR cho trang đó, và có thể nháy. - Có Suspense: chỉ vùng trong Suspense bị render lại. Suspense còn là ranh giới cô lập lỗi hydration.
suppressHydrationWarning: tắt lỗi, nhưng giữ nguyên giá trị sai của server ("10:00"). Nó chỉ dành cho text chắc chắn khác và chấp nhận được (timestamp), không phải cách "sửa" mismatch.
3.3 RSC payload
Server Component không gửi code, nó gửi kết quả theo định dạng Flight: mỗi dòng một mảnh, tham chiếu Client
Component bằng id module. Lab lấy từ HTML của /profile:
n4:["$","$L15",null,{"user":{"id":7,"name":"An","email":"an@example.com","passwordHash":"$$2y$10$SECRET_HASH_LEAKED","role":"admin"}
$L15 là tham chiếu lazy tới module của ProfileCard; phần sau là props. Payload này được nhúng vào
HTML (để hydrate) và cũng được tải riêng khi điều hướng client-side. Ai mở "View source" đều đọc được.
3.4 Server Actions: endpoint được sinh tự động
Khi build, mỗi hàm 'use server' được gán một id. Form dùng nó được render với một field ẩn
$ACTION_ID_<id> (để form chạy được cả khi JS chưa tải — progressive enhancement). Submit là POST tới chính URL
trang. Lab:
action field lấy từ HTML public: $ACTION_ID_409d56c98fd4715de3ddacce1435c66b0cfe278737
POST không cookie, không đăng nhập → HTTP 200
[server action] deleteUser id= 999 ← log phía server: action chạy với id do người gọi tự chọn
Đánh đổi thiết kế: gọi hàm server như hàm thường là trải nghiệm dev rất tốt, nhưng nó giấu mất việc đây là
một API. Không có route file, không có controller, không có middleware auth mặc định — người review dễ bỏ qua. Next.js có
kiểm tra header Origin so với host để chống CSRF (theo tài liệu Next.js; lab gọi từ Node không gửi
Origin nên không kiểm được phần này) — nhưng nó không chống được việc kẻ tấn công tự gọi thẳng.
3.5 Next.js 16: Cache Components, 'use cache', PPR
Khi bật cacheComponents: true, quy tắc mới rất rõ: mọi thứ động mặc định, cache phải khai báo, và
dữ liệu động phải nằm trong Suspense. Trang lab:
async function ProductList() {
'use cache'
cacheLife('hours'); cacheTag('products');
await new Promise(r => setTimeout(r, 300)); // giả lập query DB chậm
return <p>danh sách sản phẩm, tạo lúc {new Date().toISOString()}</p>;
}
async function Cart() { // đọc cookie → động, stream trong Suspense
const n = (await cookies()).get('cart')?.value ?? '0';
return <p>giỏ hàng: {n} món</p>;
}
async function refresh() { 'use server'; updateTag('products'); }
Kết quả build — Next đánh dấu route nào tĩnh, route nào PPR:
Route (app) Revalidate Expire
┌ ○ /
├ ○ /admin
├ ◐ /products 1h 1d
└ ○ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
Chạy thật với next start:
/products lần 1 4304ms | tạo lúc 2026-09-30T08:27:37.616Z | giỏ hàng: 3 ← request đầu sau khi start (khởi động nguội)
/products lần 2 41ms | tạo lúc 2026-09-30T08:27:37.616Z | giỏ hàng: 6 ← danh sách từ cache, giỏ đọc cookie mới
trước updateTag: 2026-09-30T08:27:37.616Z
POST action updateTag → HTTP 200
sau updateTag : 2026-09-30T08:28:50.496Z ← cache bị xoá, render lại
request kế : 2026-09-30T08:28:50.496Z ← bản mới được cache tiếp
updateTag vs revalidateTag (theo tài liệu Next.js 16): updateTag(tag) chỉ gọi
được trong Server Action, xoá cache ngay để chính người vừa ghi thấy dữ liệu mới (read-your-own-writes).
revalidateTag(tag, 'max') đánh dấu cũ và làm mới kiểu stale-while-revalidate; bản 16 bắt buộc tham số thứ hai
(bản một tham số bị deprecated). Lab chỉ chạy updateTag.
4. Đảm bảo và giới hạn
| Đảm bảo | Không đảm bảo / giới hạn |
|---|---|
| Code của Server Component không vào bundle client (lab: 0 file) | Dữ liệu Server Component truyền cho Client Component được giữ bí mật — nó nằm trong HTML (lab) |
Mismatch hydration được "phục hồi" bằng render lại ở client (có onRecoverableError) | Phục hồi rẻ — không Suspense thì render lại cả cây (lab) |
Next 16 chặn cookies() bên trong 'use cache' lúc build (lab) | Chặn mọi nguồn dữ liệu cá nhân — nếu bạn đọc session từ nguồn tự viết bên trong hàm cache, Next không biết (suy luận từ cơ chế, chưa verify) |
updateTag xoá cache ngay cho request kế (lab) | Cache nhất quán giữa nhiều instance khi self-host — mặc định cache nằm trong bộ nhớ từng instance; cần cache handler dùng chung hoặc 'use cache: remote' (theo tài liệu, chưa verify) |
| Props qua ranh giới được kiểm serialize (lab: lỗi khi truyền hàm) | Kiểm quyền trong Server Action — hoàn toàn là việc của bạn (lab) |
Khung lý thuyết — dời chi phí, không xoá chi phí: SSR/RSC chuyển CPU render từ máy người dùng sang server của bạn.
Mỗi request có SSR là một lần chạy React trên server. Capacity planning: đo ms CPU render mỗi request × RPS; cache
('use cache', PPR) là thứ biến chi phí theo request thành chi phí theo lần build/revalidate.
5. Failure modes
5.1 Lộ dữ liệu qua props của Client Component
/profile: HTML có passwordHash? true | có email? true
Hệ quả: rò rỉ dữ liệu — loại lỗi phải báo cáo sự cố. Không có cảnh báo nào lúc build. Chữa: data access layer
chỉ trả DTO (object đã lọc field) — hàm getUserProfile() trả { name }, không trả bản ghi DB; đặt
import 'server-only' ở đầu file DAL để import nhầm từ client là lỗi build; React có API thử nghiệm
experimental_taintObjectReference để chặn một object bị truyền sang client (theo tài liệu react.dev, chưa verify).
5.2 Server Action không kiểm quyền
Lab ở mục 3.4: trang admin "chỉ admin thấy nút", nhưng action vẫn chạy khi bị gọi thẳng. Tư duy đúng: mỗi Server Action = một route API. Trong từng action: xác thực (ai gọi), phân quyền (được làm việc này với bản ghi này không), validate input (zod), rate limit. Tài liệu React ghi rõ: đối số của Server Function hoàn toàn do client kiểm soát.
5.3 Hydration mismatch
Nguồn hay gặp: new Date()/Date.now(), Math.random(), toLocaleString()
(server UTC, client giờ VN), đọc localStorage/window trong render, extension của browser chèn thuộc tính,
HTML sai cấu trúc (<div> trong <p>). Chữa: đưa giá trị khác nhau vào useEffect
(render lần đầu giống server), truyền giá trị từ server xuống làm props, hoặc chỉ render phần đó ở client. Log
onRecoverableError về APM — mặc định chỉ hiện trong console.
5.4 Dữ liệu động ngoài Suspense → build fail
Error: Route "/admin": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>`
prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Đây là "failure" tốt: lỗi ở build thay vì chậm ở production. Nhưng khi nâng cấp dự án cũ lên cacheComponents,
hàng chục route có thể fail cùng lúc — cần kế hoạch migrate từng route.
5.5 Cache dữ liệu cá nhân dùng chung
Error: Route /me used `cookies()` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported.
If you need this data inside a cached function use `cookies()` outside of the cached function and pass the required dynamic
data in as an argument.
Next chặn trường hợp rõ ràng. Trường hợp nguy hiểm còn lại: hàm cache không nhận userId làm tham số nhưng
lấy user từ nơi khác → cache key giống nhau cho mọi người → user B thấy đơn hàng của user A. Quy tắc: mọi thứ làm kết quả
khác nhau phải là tham số của hàm cache (tham số là một phần của cache key) — giống hệt bài học query key ở
bài State.
5.6 Lỗ hổng của chính framework (postmortem công khai)
- CVE-2025-29927 (3/2025) — bypass middleware của Next.js. Gửi header nội bộ
x-middleware-subrequestlàm Next bỏ qua middleware; app nào chỉ kiểm tra đăng nhập trong middleware bị vượt qua hoàn toàn. Nguồn: advisory của Vercel/Next.js. Bài học: middleware (Next 16 đổi tên thànhproxy) chỉ là lớp lọc sớm, auth phải kiểm lại ở nơi đọc dữ liệu. - CVE-2025-55182 "React2Shell" (12/2025) — thực thi code từ xa (RCE) trong React Server Components. Lỗi ở phần giải
tuần tự hoá (deserialize) request gửi tới Server Function trong các gói
react-server-dom-*19.x; ảnh hưởng Next.js App Router và các framework dùng RSC; điểm CVSS 10.0, bị khai thác thực tế ngay sau khi công bố. Sau đó có thêm các CVE về DoS và lộ source code của Server Function. Nguồn: blog react.dev và advisory của Next.js tháng 12/2025. Tóm tắt theo advisory, không tái hiện — kiểm tra lại số bản đã vá trước buổi phỏng vấn.
Bài học tech lead: dùng RSC là thêm một server công khai nhận input từ internet. Nó cần quy trình vá lỗi như mọi backend: theo dõi advisory, có khả năng deploy bản vá trong vài giờ, WAF phía trước.
Phát hiện
| Lỗi | Phát hiện bằng |
|---|---|
| Lộ dữ liệu | Test tự động: render trang, grep HTML tìm field nhạy cảm (password, token, secret) |
| Action không auth | Review: grep 'use server', mỗi hàm phải có lời gọi auth ở dòng đầu; test gọi action không cookie |
| Hydration | onRecoverableError → APM; Sentry có tích hợp sẵn |
| Framework CVE | Dependabot/Renovate + đăng ký advisory của React và Next.js |
6. Vận hành trong production
Metric
| Metric | Ngưỡng tham khảo | Liên quan |
|---|---|---|
| TTFB | < 800ms | SSR chậm, cache miss, cold start (lab: request đầu 4304ms, sau đó 41ms) |
| LCP (p75) | < 2,5s | Streaming, ảnh, font |
| INP (p75) | < 200ms | Hydration nặng chặn tương tác |
| CLS | < 0,1 | Fallback của Suspense khác kích thước nội dung thật |
| Số lỗi hydration / 1000 lượt xem | 0 | Mismatch sau mỗi release |
| CPU/request của Node server, p99 thời gian render | Theo capacity | SSR tốn CPU |
| JS tải cho route đầu | Theo ngân sách team | Quá nhiều 'use client' |
Các ngưỡng Web Vitals theo web.dev; chưa verify bằng browser trong lab.
Cấu hình và hạ tầng
- Proxy phía trước phải cho stream đi qua — và thủ phạm thường là gzip, không phải buffering. Lab: server React
stream (shell ngay, phần chậm sau 800ms) đặt sau nginx 1.31.3, đo thời điểm từng chunk tới client:
Hệ quả: trang vẫn chạy đúng, chỉ là TTFB bằng thời gian của phần chậm nhất — không ai báo lỗi, chỉ thấy LCP xấu đi sau khi "bật gzip cho nhanh". Và Next.js 16.3.7 không tự gửitrực tiếp Node chunk: +206ms +1014ms ← shell tới trước nginx mặc định (buffering on, gzip off) chunk: +47ms +822ms ← vẫn stream được nginx gzip on, buffering on chunk: +884ms (1 chunk duy nhất) ← MẤT streaming chạy lại 3 lần: +2300ms / +1767ms / +1198ms, lần nào cũng 1 chunk ở cuối nginx gzip on + proxy_buffering off chunk: +24ms +829ms ← stream lại nginx gzip on + header X-Accel-Buffering: no từ app → cũng stream lại (chunk đầu tới trước)X-Accel-Buffering: no(lab đọc header của/products:null) — phải tự đặtproxy_buffering offcho location của Next hoặc thêm header. Số ms dao động vì máy lab đang tải; mẫu hình (1 chunk hay nhiều chunk) lặp lại ổn định. CDN/WAF phía trước cũng phải kiểm tương tự (chưa verify). - Nhiều instance → cấu hình cache handler dùng chung (Redis) cho
'use cache', nếu không mỗi pod một cache vàupdateTagchỉ xoá ở pod nhận request (theo tài liệu, chưa verify). - Cold start: warm-up sau deploy (gọi các route chính) trước khi nhận traffic.
Upgrade
Next.js đổi lớn gần như mỗi năm: 13 (App Router), 14 (Server Actions ổn định), 15 (fetch không còn cache mặc
định, API request thành async), 16 (bỏ hẳn truy cập đồng bộ params/cookies(), middleware →
proxy, revalidateTag cần tham số thứ hai, Turbopack mặc định, Cache Components). Mỗi lần là một
đợt migrate thật — ghi vào roadmap, không coi là "bump version".
Gánh nặng cho team
Debug trải trên hai môi trường: log server (Node) và console browser. Người trực cần biết đọc cả hai và biết một lỗi hiện ở client có thể bắt nguồn từ payload server. Thêm một runtime (Node) để vá, giám sát, scale — với team Laravel đây là chi phí thật.
7. So sánh và ma trận quyết định
| Tiêu chí | SPA (Vite + React) | Next.js App Router (RSC) | React Router 7 (framework mode) | Astro (islands) | Laravel + Inertia (React) |
|---|---|---|---|---|---|
| SEO / LCP trang công khai | Kém | Tốt nhất | Tốt | Tốt nhất cho nội dung | Tốt nếu bật SSR |
| JS gửi xuống | Nhiều | Ít (chỉ Client Component) | Vừa | Rất ít | Vừa |
| Độ phức tạp | Thấp | Cao | Vừa | Thấp–vừa | Thấp cho team Laravel |
| Hạ tầng | File tĩnh + API | Node server (hoặc Vercel) | Node server | Tĩnh hoặc Node | PHP như cũ (+ Node nếu SSR) |
| Vendor lock-in | Thấp | Vừa (tính năng tốt nhất trên Vercel) | Thấp | Thấp | Thấp |
| Bề mặt bảo mật mới | Không | Có (Server Action, RSC endpoint) | Có (action/loader) | Ít | Không (vẫn là controller Laravel) |
Dùng Next.js + RSC khi: trang công khai cần SEO/LCP (e-commerce, nội dung), có người vận hành Node, chấp nhận học mô hình mới.
Tránh khi: app nội bộ sau đăng nhập (dùng SPA); đã có backend Laravel và chỉ cần giao diện tương tác (Inertia giữ auth, validate, policy trong Laravel — không phải viết lại ở Server Action); team không có người trực Node.
8. Góc nhìn tech lead: tác động tổ chức
Viết theo ADR cho bối cảnh "đã có Laravel API, thêm frontend React":
| Mục | Nội dung |
|---|---|
| Quyết định | Next.js chỉ làm tầng hiển thị và BFF (backend-for-frontend) mỏng. Laravel giữ vai trò nguồn sự thật: nghiệp vụ, auth, policy, validate. |
| Vì sao | Tránh hai nơi cùng quyết định phân quyền. Server Action chỉ gọi Laravel API kèm token của user, không truy cập DB trực tiếp. |
| Hệ quả | Thêm một hop mạng (Next → Laravel) mỗi request; cần cache ở Next cho dữ liệu công khai; cần truyền auth (cookie/token) qua hai tầng. |
| Rủi ro | Next đổi lớn hằng năm; CVE framework (React2Shell) — phải vá trong vài giờ; lock-in Vercel nếu dùng tính năng chỉ có ở đó. |
| Exit strategy | Client Component viết không phụ thuộc Next (không import next/* trong component UI chung); data access layer tách riêng; nếu bỏ Next thì UI chạy lại được với Vite/React Router, dữ liệu vẫn từ Laravel API. |
Tổ chức team: ranh giới server/client làm mờ ranh giới FE/BE. Cần quy định ai review Server Action (người có tư
duy bảo mật backend), và checklist bảo mật riêng cho 'use server'. Onboarding dev frontend thuần cần học về
auth, validate, rate limit — những thứ trước đây backend lo.
9. Anti-patterns và bài học thực tế
'use client'ở layout gốc → mọi thứ bên dưới thành Client Component, mất hết lợi ích RSC. Đẩy'use client'xuống lá (nút, form, dropdown).- Truyền nguyên bản ghi DB cho Client Component (lab: lộ
passwordHash). - Auth chỉ ở middleware/proxy (CVE-2025-29927).
- Server Action không kiểm quyền, không validate (lab: xoá được user 999).
- Dùng Server Action để đọc dữ liệu: tài liệu React nói Server Function dành cho mutation; chúng chạy qua POST, không cache được như GET.
- Tắt mismatch bằng
suppressHydrationWarning→ màn hình giữ giá trị sai (lab: "10:00"). - Không có Suspense → một lỗi hydration nhỏ làm render lại cả trang; một API chậm chặn cả trang.
- Fetch ở Client Component những gì Server Component lấy được → quay lại chuỗi thác request của SPA.
- Chuỗi thác trên server:
await a(); await b(); await c();tuần tự trong Server Component khi chúng độc lập → dùngPromise.allhoặc tách thành các component Suspense song song.
Điều người làm lâu đều biết: RSC giải quyết bài toán "gửi quá nhiều JS" và "chuỗi thác request". Nếu app của bạn không có hai bài toán đó (app nội bộ, ít trang), RSC chỉ thêm độ phức tạp và bề mặt tấn công.
10. Câu hỏi phụ interviewer hay hỏi tiếp
H: SSR và RSC khác nhau thế nào?
Đ: SSR là render component (cả Client Component) ra HTML trên server cho lần tải đầu; code vẫn gửi xuống và hydrate. RSC là
loại component chỉ chạy trên server, không bao giờ gửi code, không hydrate. Hai thứ dùng cùng nhau: RSC tạo cây, SSR biến cây
đó thành HTML.
H: Server Component có dùng useState được không?
Đ: Không. Nó chạy một lần trên server, không có vòng đời ở client. Cần tương tác thì tách phần đó thành Client Component.
H: Client Component có render được Server Component bên trong không?
Đ: Không import trực tiếp được, nhưng nhận được qua children/props: Server Component cha render cả hai và truyền
Server Component vào như children.
H: Hydration mismatch xử lý thế nào?
Đ: Tìm nguồn khác nhau (thời gian, random, locale, browser API) và làm lần render đầu ở client giống server; giá trị chỉ có ở
client thì đặt trong useEffect. Bọc Suspense để cô lập. Không dùng suppressHydrationWarning để che.
H: Server Action an toàn không?
Đ: An toàn ngang một API route bạn viết — tức là tuỳ bạn. Nó là endpoint POST công khai; lab gọi được không cần đăng nhập.
Kiểm auth, quyền và validate trong từng action.
H: Next.js 16 cache khác 14/15 thế nào?
Đ: 14 cache fetch mặc định (ngầm, gây nhiều bất ngờ); 15 bỏ cache mặc định; 16 với Cache Components thì cache
hoàn toàn tường minh bằng 'use cache' + cacheLife + cacheTag, dữ liệu động bắt buộc trong
Suspense, và PPR trộn khung tĩnh với phần động.
H: Streaming có hại gì cho SEO hay status code không?
Đ: Khi shell đã gửi thì status 200 và header đã đi — lỗi xảy ra sau đó trong vùng Suspense không đổi được thành 500 hay
redirect. Việc cần quyết định status (404, redirect, auth) phải làm trước khi gửi shell.
H: Khi nào anh không dùng Next.js?
Đ: App nội bộ sau đăng nhập; hệ thống Laravel sẵn có chỉ cần vài màn tương tác (Inertia); team không vận hành được Node; hoặc
không muốn phụ thuộc vào nhịp đổi lớn hằng năm của Next.
Phụ lục: lab đã chạy
Môi trường: Docker node:22-bookworm-slim (Node 22.23.3), React 19.3.0, react-dom 19.3.0, jsdom 26.1,
Next.js 16.3.7, TypeScript. Container tạm, không expose port ra host — mọi request gọi bằng fetch từ trong container.
SSR streaming và hydration:
docker exec -e NODE_ENV=production react-lab npx tsx 10-ssr-stream.tsx
docker exec react-lab npx tsx 11-hydration.tsx
// 10-ssr-stream.tsx (phần chính)
const Reviews = () => <section>{use(slow('reviews', 800))}</section>;
const Page = () => <html><body><h1>Sản phẩm</h1><p>Giá 199k</p>
<Suspense fallback={<p>Đang tải đánh giá…</p>}><Reviews /></Suspense></body></html>;
renderToString(<Page />); // trả fallback ngay
renderToPipeableStream(<Page />, { onShellReady() { pipe(writableGhiThoiGianMoiChunk); } });
// 11-hydration.tsx (phần chính)
env = 'server'; el.innerHTML = renderToString(<App />);
env = 'client'; hydrateRoot(el, <App />, { onRecoverableError: e => errors.push(e.message) });
Next.js 16:
mkdir nx && docker exec -w /lab/nx react-lab sh -c 'npm init -y && npm i next@16.3.7 react@19.3.0 react-dom@19.3.0 typescript @types/react @types/node'
# next.config.ts: export default { cacheComponents: true }
# app/products/page.tsx, app/profile/{page.tsx,ProfileCard.tsx}, app/admin/page.tsx — code như trong bài
docker exec -w /lab/nx -e NEXT_TELEMETRY_DISABLED=1 react-lab npx next build
docker exec -d -w /lab/nx react-lab sh -c 'npx next start -p 3000 > server.log 2>&1'
# gọi Server Action như kẻ tấn công: lấy $ACTION_ID từ HTML công khai rồi POST
docker exec react-lab node -e "
(async () => {
const h = await (await fetch('http://localhost:3000/admin')).text();
const id = h.match(/name=\"(\\\$ACTION_ID_[0-9a-f]+)\"/)[1];
const fd = new FormData(); fd.append(id, ''); fd.append('id', '999');
console.log((await fetch('http://localhost:3000/admin', { method: 'POST', body: fd })).status);
})();"
grep 'server action' nx/server.log
# code Server Component có vào bundle client không
grep -rl "danh sách sản phẩm, tạo lúc" nx/.next/static | wc -l # 0
grep -rl "Xin chào" nx/.next/static | wc -l # 1 (ProfileCard là Client Component)
Streaming qua nginx (nginx:alpine 1.31.3, network docker riêng react-lab-net):
# 12-stream-server.tsx: http server gọi renderToPipeableStream, phần chậm 800ms; URL /noaccel thêm header X-Accel-Buffering: no
docker network create react-lab-net && docker network connect react-lab-net react-lab
docker exec -d -e NODE_ENV=production react-lab sh -c 'npx tsx 12-stream-server.tsx'
# nginx.conf: 4 server block
# :80 mặc định | :81 proxy_buffering off | :82 gzip on | :83 gzip on + proxy_buffering off
docker run -d --name react-lab-nginx --network react-lab-net -v $PWD/nginx.conf:/etc/nginx/nginx.conf:ro nginx:alpine
# probe: in thời điểm mỗi chunk tới, gửi Accept-Encoding: gzip
http.get(url, { headers: { 'accept-encoding': 'gzip' } }, res => res.on('data', () => log(Date.now() - t0)))
docker rm -f react-lab-nginx && docker network rm react-lab-net # dọn
Hai lỗi build (dữ liệu động ngoài Suspense, cookies() trong 'use cache') và lỗi truyền hàm qua
props được tạo bằng cách thêm tạm một route, build, chép lỗi, rồi xoá route.