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 start thật trong container tạm, gọi bằng fetch từ 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.
hydrationReact ở browser "nhận lại" HTML do server tạo: gắn event handler, dựng Fiber, không vẽ lại.
streaming SSRGử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 ComponentComponent có 'use client': code được gửi xuống browser, được hydrate, dùng được state/effect.
Server Function / Server ActionHà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 / LCPThờ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ũ renderToString phả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ấy passwordHash lộ 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ếtCòn vướng
CSR (SPA, 2013–)Tương tác mượt, deploy như file tĩnhTrang 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, SEOPhả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ầnVẫ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 serverMô 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 ComponentsCache rõ ràng bằng 'use cache', PPR mặc định khi bậtPhải học lại mô hình cache (lần thứ ba trong 3 năm)
SSR + RSC tối ưu choKHÔNG tối ưu cho
Trang công khai cần SEO và LCP: e-commerce, nội dung, landingApp 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 databaseHệ 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.

Ranh giới server và client là ranh giới qua mạng Bên trái là server: Server Component đọc database, có user đầy đủ gồm name, email, passwordHash, role. Ở giữa là ranh giới 'use client'. Một gói props chạy qua ranh giới sang bên phải là browser, nơi Client Component ProfileCard chỉ hiện name. Nhưng gói props mang theo mọi field, và nó nằm nguyên trong HTML/RSC payload mà ai cũng xem được — passwordHash sáng đỏ bên phía browser. SERVER await getUser() { name, email, passwordHash, role } code server: 0 byte ở browser (lab: grep chunks = 0 file) 'use client' = serialize qua mạng BROWSER <ProfileCard user={…}/> chỉ hiện: "Xin chào An" HTML có: "passwordHash": "$2y$10$SECRET…" props: user
Client Component chỉ dùng 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:

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:

Streaming SSR: shell tới trước, phần chậm tới sau Trục thời gian từ 0 tới khoảng 1 giây. Ở 31ms, chunk đầu tới trình duyệt: tiêu đề, giá, và dòng "Đang tải đánh giá". Ở 827ms, chunk thứ hai tới, script inline thay dòng "Đang tải" bằng nội dung đánh giá. Hàng dưới so sánh: nếu dùng renderToString và đợi hết dữ liệu, trình duyệt trắng trơn tới khoảng 800ms. 0ms~1s chunk 1 (31ms) chunk 2 (827ms) browser — streaming Sản phẩmGiá 199k Đang tải đánh giá… ★★★★☆ reviews xong renderToString + đợi hết dữ liệu: màn hình trắng tới ~800ms rồi mới có byte đầu streaming: khung trang ở 31ms, chỉ riêng khối đánh giá phải chờ
Streaming không làm API chậm nhanh hơn. Nó chỉ không để API chậm chặn phần còn lại của trang.

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ó)

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ảoKhô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)

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ỗiPhát hiện bằng
Lộ dữ liệuTest tự động: render trang, grep HTML tìm field nhạy cảm (password, token, secret)
Action không authReview: 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
HydrationonRecoverableError → APM; Sentry có tích hợp sẵn
Framework CVEDependabot/Renovate + đăng ký advisory của React và Next.js

6. Vận hành trong production

Metric

MetricNgưỡng tham khảoLiên quan
TTFB< 800msSSR chậm, cache miss, cold start (lab: request đầu 4304ms, sau đó 41ms)
LCP (p75)< 2,5sStreaming, ảnh, font
INP (p75)< 200msHydration nặng chặn tương tác
CLS< 0,1Fallback của Suspense khác kích thước nội dung thật
Số lỗi hydration / 1000 lượt xem0Mismatch sau mỗi release
CPU/request của Node server, p99 thời gian renderTheo capacitySSR tốn CPU
JS tải cho route đầuTheo ngân sách teamQuá 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

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 khaiKémTốt nhấtTốtTốt nhất cho nội dungTốt nếu bật SSR
JS gửi xuốngNhiềuÍt (chỉ Client Component)VừaRất ítVừa
Độ phức tạpThấpCaoVừaThấp–vừaThấp cho team Laravel
Hạ tầngFile tĩnh + APINode server (hoặc Vercel)Node serverTĩnh hoặc NodePHP như cũ (+ Node nếu SSR)
Vendor lock-inThấpVừa (tính năng tốt nhất trên Vercel)ThấpThấpThấp
Bề mặt bảo mật mớiKhôngCó (Server Action, RSC endpoint)Có (action/loader)ÍtKhô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ụcNội dung
Quyết địnhNext.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ì saoTrá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 roNext đổ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 strategyClient 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ế

  1. '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).
  2. Truyền nguyên bản ghi DB cho Client Component (lab: lộ passwordHash).
  3. Auth chỉ ở middleware/proxy (CVE-2025-29927).
  4. Server Action không kiểm quyền, không validate (lab: xoá được user 999).
  5. 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.
  6. Tắt mismatch bằng suppressHydrationWarning → màn hình giữ giá trị sai (lab: "10:00").
  7. 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.
  8. Fetch ở Client Component những gì Server Component lấy được → quay lại chuỗi thác request của SPA.
  9. 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ùng Promise.all hoặ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.