Kỹ Thuật Chuyên Sâu • 40 phút đọc • 08/10/2026

Next.js Bên Trong: Tại Sao Lại Có RSC, Streaming Và Cache Components

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Máy in báo công nghiệp
Ảnh: Samuel Loxton (artist) · Public domain

Next.js không phải một framework được xây từ số không. Bản chất nó là React cộng thêm một tầng vận hành: một server, một router, một bundler, và một mô hình render.

Nhưng có một câu hỏi nằm dưới tất cả, và mình nhận ra rất ít người trả lời được:

Tại sao React SSR được?

Không phải “làm sao để bật SSR”. Mà là: React viết ra để chạy trên browser, cập nhật DOM. Vậy bằng cách nào nó chạy được trong Node để sinh ra một chuỗi HTML? Cái gì đã được thiết kế để cho phép điều đó?

Trả lời được câu này thì mọi feature còn lại của Next.js tự sáng ra, vì tất cả chúng đều là hệ quả của cùng một kiến trúc.

Nên bài này đi theo thứ tự đó. Phần 1 là câu trả lời nền tảng. Phần sau là từng feature, mỗi cái trình bày theo một khuôn: vấn đề gì → tại sao cách cũ không đủ → nó giải gì → nó lấy đi cái gì.

1. Tại sao React SSR được: vì React không biết DOM

Câu trả lời nằm ở một quyết định kiến trúc từ React 16, và nó không phải chuyện “thêm một API render”.

Element của React là một object JavaScript thuần. Không phải DOM node, không phải chuỗi. Toàn bộ cây UI của bạn, ở tầng thấp nhất, chỉ là dữ liệu:

// Một element React thực chất chỉ là thế này
{ type: 'div', props: { className: 'box', children: [...] } }

Vì nó là dữ liệu, nên có thể đưa nó cho bất kỳ thứ gì biết đọc dữ liệu đó và biến thành thứ khác. Và React có nhiều thứ như vậy.

1.1. Ba renderer, không phải một

Chỗ này đáng để cài lại niềm tin. react-dom mà bạn import trong app là client renderer. Nhưng react-dom/server không phải “cùng thứ đó chạy trong Node”. Nó là một implementation khác.

Mình tải [email protected] từ npm và đếm bằng grep -o <tên> <file> | wc -l:

react-dom-client.development.js        (client)
    beginWork             4
    completeWork          2
    performUnitOfWork     3
    workLoopConcurrent    2

react-dom-server.node.development.js   (server)
    beginWork             0
    completeWork          0
    performUnitOfWork     0
    workLoopConcurrent    0
    ───────────────────────
    Segment             316
    Request              48
    pingTask              5
    writeChunk          172

Đọc theo hàng dọc: bốn cái tên của Fiber đều bằng không ở server. Chiều ngược lại cũng vậy — Segment, pingTask và writeChunk không xuất hiện lần nào trong client build. Hai bên không chia sẻ bộ máy.

Không một dòng Fiber nào trong server build. Không beginWork, không completeWork, không workLoopConcurrent. Không có cây fiber, không có hai cây current/workInProgress, không có lane.

Vậy server build có gì? Một model khác hẳn: Request → Task → Segment.

Ba renderer của React: Fiber cho client, Fizz cho HTML, Flight cho RSC

Cùng một cây element, ba bộ máy khác nhau ở đầu ra.

Ba renderer đó là:

  • Fiber (react-dom-client) — đầu ra là DOM. Có incremental rendering, có ưu tiên, có thể ngắt giữa chừng, giữ state qua nhiều lần render.
  • Fizz (react-dom-server) — đầu ra là chuỗi/stream HTML. Một chiều, không quay lại, không giữ state.
  • Flight (package riêng: react-server-dom-webpack, react-server-dom-turbopack, react-server-dom-parcel, đều 19.3.0) — đầu ra là payload RSC. Không phải HTML.

react-dom thì không hề chứa Flight (0 khớp). Nó là một package độc lập, đúng như thiết kế.

1.2. Vì sao phải là ba, không phải một

Vì yêu cầu của ba môi trường ngược nhau, và cố làm một cái phục vụ cả ba là cách chắc chắn nhất để nó tệ ở cả ba.

Fiber phải làm được những thứ Fizz không cần. Fiber cập nhật DOM đang sống. Nó phải giữ state giữa các lần render (state của bạn nằm trên fiber, không nằm trong closure). Nó phải ưu tiên được — click trước, transition sau. Nó phải ngắt giữa chừng rồi chạy lại. Nó phải double-buffer để vứt bỏ được cây đang dựng dở.

Fizz không cần một thứ nào trong số đó. Server render là một chiều: đi từ trên xuống, ghi ra byte, không bao giờ quay lại sửa. Không có state của người dùng để giữ. Không có tương tác để ưu tiên. Không có “render lại” — chỉ có một lần. Và cây fiber là cấu trúc dữ liệu nặng, gắn liền với vòng đời dài trong browser; ném nó vào mỗi request server là trả giá cho thứ không ai dùng.

Bỏ hết yêu cầu của Fiber đi, Fizz trở nên đơn giản tới mức có thể tồn tại. Đó là toàn bộ câu trả lời cho “tại sao React SSR được”.

1.3. Bằng chứng: Fizz nghĩ bằng Task, không bằng cây

Mình mở file server build ra và đây là cấu trúc task thật (rút gọn):

// react-dom-server.node.development.js — object task thật, giữ nguyên tên field
var task = {
  node,                 // node đang render
  childIndex,
  ping: { resolve, reject },   // <-- chỗ này quan trọng
  blockedBoundary,             // Suspense boundary đang chặn nó
  blockedSegment,
  abortSet,                    // task có thể bị huỷ
  keyPath,
  context,
  treeContext,
  thenableState,
}

Hàm vào của toàn bộ server renderer:

function createRequest(
  children, resumableState, renderState, rootFormatContext,
  progressiveChunkSize, onError, onBrowserBailout,
  onAllReady, onShellReady, onShellError, onFatalError, formState
)

Đọc kỹ ping. Khi một component suspend — tức là ném ra một promise — Fizz không block. Nó đăng ký hai callback resolve/reject lên promise đó, tăng bộ đếm, rồi đi tiếp sang node khác.

request.allPendingTasks++
blockedBoundary === null
  ? request.pendingRootTasks++
  : blockedBoundary.pendingTasks++

Đó là toàn bộ bí mật của streaming SSR. Khi promise resolve, pingTask chạy, task đó tiếp tục, và output của nó được writeChunk ghi ra như một mảnh mới. Khi pendingRootTasks về 0, shell xong, onShellReady bắn, startFlowing bắt đầu đẩy byte.

Vì Fizz không giữ cây, nó không cần “chỗ” cho mảnh mới. Nó chỉ cần ghi ra cuối stream và gọi $RC() để client tự ghép — chi tiết đó mình đã phân tích ở bài SSR.

Ba renderer, ba khuôn mặt của cùng một cây element. Giờ quay lại Next.js.

2. Khởi nguồn: CSR chậm vì waterfall ba vòng

Toàn bộ câu chuyện bắt đầu ở đây, nên phải kể đúng.

Với CSR thuần, một lượt tải trang là ba vòng nối tiếp: tải HTML rỗng → tải bundle JS → gọi API. Không vòng nào chồng lên vòng nào, vì vòng sau cần kết quả vòng trước.

Hệ quả thật: nội dung không thể xuất hiện trước khi JavaScript tải xong. Mọi thứ khác — LCP tệ, SEO kém, flash of loading — chỉ là biểu hiện của một nguyên nhân đó.

SSR ra đời để cắt ba vòng thành một. Server chạy React, có sẵn dữ liệu, gửi HTML đã hoàn chỉnh.

Nội dung tới sau một vòng thay vì ba, nên FCP và LCP tốt lên rõ rệt. Đổi lại, TTFB tăng — server phải chờ dữ liệu rồi mới gửi byte đầu. Và một khái niệm mới xuất hiện: hydrate.

Chính chỗ này lộ ra vấn đề khiến mọi feature sau đó của Next.js tồn tại.

3. SSR cổ điển: tất cả hoặc không có gì

SSR cổ điển — thời renderToString — có một tính chất tàn nhẫn:

Trang chậm nhất quyết định thời điểm của mọi thứ.

Server phải chạy xong toàn bộ cây, chờ mọi dữ liệu, rồi mới ghi byte đầu tiên. Nếu header của bạn render xong sau 5ms nhưng một khối “sản phẩm gợi ý” cần 800ms, người dùng chờ 800ms để thấy — header.

Bạn không thể gửi header trước, vì renderToString trả về một chuỗi. Chuỗi thì phải xong mới có. Và bạn cũng không thể đổi status code sau đó, vì chưa gửi gì cả.

Vấn đề không nằm ở React chậm. Nằm ở việc API chỉ có một hình dạng đầu ra: chuỗi hoàn chỉnh.

3.1. Streaming thay đổi hình dạng đầu ra

React 18 thêm renderToPipeableStream bên cạnh renderToString, và đổi hình dạng đầu ra từ “chuỗi” sang “stream”.

Một chi tiết dễ viết sai, nên nói rõ: renderToString không bị xoá. Nó vẫn nằm trong react-dom-server-legacy ở React 19.3.0. Nó chỉ không hỗ trợ Suspense — trong chính file đó có câu cảnh báo “which does not support Suspense. If you intended to have the server wait for the suspended component please switch to…”. Nên nếu bạn còn dùng renderToString và tự hỏi vì sao <Suspense> không có tác dụng gì, câu trả lời nằm ở đó.

Điều này khả thi chỉ vì Fizz nghĩ bằng Task. Mỗi vùng chờ dữ liệu là một task có ping. Server không block. Nó ghi shell trước, rồi mỗi task xong thì ghi thêm một mảnh.

flowchart TD
    A["renderToPipeableStream"] --> B["createRequest(...)<br/>đăng ký onShellReady, onAllReady"]
    B --> C["renderNodeDestructive<br/>đi từng node"]
    C --> D{"Component có<br/>suspend không?"}
    D -->|Không| E["writeChunk ngay"]
    D -->|Có| F["tạo Task với ping<br/>KHÔNG block, đi tiếp"]
    E --> G{"pendingRootTasks<br/>về 0?"}
    F --> G
    G -->|Rồi| H["onShellReady<br/>startFlowing — bắt đầu gửi"]
    G -->|Chưa| C
    F -.->|"promise resolve"| I["pingTask<br/>task chạy tiếp"]
    I --> J["writeChunk mảnh mới<br/>+ script $RC()"]

    style F fill:#3b2f1e,stroke:#f59e0b,color:#fff
    style H fill:#1e3f2f,stroke:#34d399,color:#fff

Trang nhanh nhất không còn bị trang chậm nhất giữ làm con tin. Shell hiện trong vài chục ms, các vùng về sau theo tốc độ riêng của chúng.

Cái giá là ba thứ, và cả ba đều gây bug thật.

  • Status code chốt sớm. Shell đã gửi thì không đổi được nữa. Lỗi xảy ra sau đó không trả được 500 — chỉ hiện error boundary tại chỗ, response vẫn 200.
  • Crawler cần đường riêng. Một số bot không xử lý được stream. Nên phải phân nhánh: người dùng thì onShellReady, bot thì onAllReady.
  • Hạ tầng phải biết stream. Proxy buffer, nén không streaming, CDN gom response — cả ba giết sạch lợi ích. Loại bug này im lặng: code đúng, hạ tầng sai.

Một điều kiện tiên quyết mà nhiều người bỏ qua: Suspense cần một tầng dữ liệu biết suspend. useEffect không suspend. Fetch trong useEffect không tạo ra fallback, không có gì để server stream. Phải dùng use() của React 19, hoặc một primitive của framework.

4. Streaming không cứu được khối lượng JavaScript

Streaming giải bài toán thời điểm. Nó không giải bài toán khối lượng.

Dù HTML tới sớm, browser vẫn phải tải toàn bộ JS của mọi component để hydrate. Với một trang thương mại điện tử, đó là bundle chứa cả những component mà người dùng chưa từng nhìn thấy. Và hydrate là lần render thứ hai — component chạy lại trên main thread của một chiếc điện thoại.

Chính đó là vấn đề mà RSC ra đời để giải.

Ranh giới RSC: use client cắt cây tại một điểm

'use client' cắt cây tại một điểm. Mọi thứ import trong file đó vào bundle client — kể cả khi không bao giờ render.

4.1. Bỏ code đi, giữ kết quả

Ý tưởng: nếu một component chỉ dùng để render ra nội dung, và không cần tương tác, thì tại sao phải gửi code của nó xuống browser?

Server component chạy ở server, và browser chỉ nhận kết quả — không nhận code. Component đó không tồn tại trong bundle client. Thư viện markdown nặng, ORM, SDK nội bộ — ở lại server.

Đó là điểm khác biệt cốt lõi so với SSR. SSR gửi HTML đã render nhưng vẫn gửi code để hydrate. RSC gửi HTML đã render và không gửi code.

Bundle client nhỏ đi, đôi khi rất nhiều. Hydrate ít việc hơn. Data fetching dời về gần dữ liệu.

Đổi lại, ba thứ mới phải quản:

  • Một ranh giới mới phải quản. 'use client' cắt cây tại một điểm. Đặt sai chỗ là kéo cả subtree vào bundle client.
  • Serialization phải được thiết kế. Props qua biên giới phải đi qua Flight protocol, nên phải serialize được.
  • Một bề mặt tấn công mới. Flight deserialize payload do client gửi lên. Tháng 12/2025, CVE-2025-55182 khai thác đúng chỗ này — CVSS 10.0, chạy mã từ xa không cần xác thực. Đã vá ở React 19.0.1 / 19.1.2 / 19.2.1.

Một hiểu lầm phổ biến cần dẹp: RSC không thay thế SSR. Client component vẫn được SSR ở lần tải đầu để sinh HTML, rồi vẫn hydrate. RSC và SSR là hai lớp, không phải hai lựa chọn.

4.2. Flight: vì sao cần protocol riêng

Cây RSC chứa component, promise, module reference — không JSON.stringify được. Nên React cần một format riêng.

Định dạng Flight: chunk đánh số và cú pháp tham chiếu $

Chunk 0 giữ hình dạng cây. Các chunk sau chở dữ liệu thật, tham chiếu bằng $.

flowchart LR
    A["Cây server component"] --> B["Flight serialize<br/>thành chunk đánh số"]
    B --> C["$ tham chiếu chunk khác"]
    B --> D["$L1 = client reference<br/>'client tự import module này'"]
    B --> E["$K = FormData, $W = Set,<br/>$@ = chưa resolve"]
    C --> F["HTML stream<br/>self.__next_f.push"]
    F --> G["Client: React ghép cây<br/>+ import client component"]

    style B fill:#2a1e3f,stroke:#a78bfa,color:#fff

Phân biệt hai chiều tham chiếu, vì đây là chỗ hay lẫn:

  • Client reference — server bảo client “chỗ này là component ở module Y, tự import đi”. Đây là đường code client đi xuống.
  • Server reference — client bảo server “chạy hàm Z”. Đây là cơ chế Server Action.

5. Dữ liệu và component bị tách khỏi nhau

Vấn đề này cũ hơn RSC và có từ Pages Router.

Trong getServerSideProps, dữ liệu được fetch ở một hàm riêng, rồi truyền vào component qua props. Component không biết dữ liệu từ đâu. Muốn đổi cách lấy dữ liệu thì phải sửa hai chỗ. Và mọi thứ phải chờ hàm đó xong trước khi render bắt đầu — không có Suspense, không có streaming, vì render chưa hề bắt đầu.

Vấn đề không phải cú pháp. Mà là dữ liệu và UI bị tách khỏi nhau, và server mất khả năng stream vì nó phải chờ tất cả trước khi vẽ một pixel.

App Router cho fetch ngay trong component, và vì component là async, <Suspense> trở thành ranh giới tự nhiên cho việc stream.

Cái được là colocation — chỗ lấy dữ liệu nằm cạnh chỗ dùng dữ liệu — và streaming trở thành mặc định về mặt cấu trúc.

Cái mất là khả năng nhìn thấy toàn bộ dữ liệu của một trang ở một chỗ. Muốn biết trang này gọi API nào, bạn phải đọc cả cây component. Với người mới vào dự án, đó là một bước lùi thật.

6. Đổi dữ liệu mà không phải build lại

SSG cho tốc độ tốt nhất — HTML đã có sẵn, không render gì cả. Nhưng nó có một giả định: nội dung không đổi.

Với một trang sản phẩm có 50.000 mục, mỗi lần đổi giá là một lần build lại toàn bộ. Không khả thi.

ISR giải bài toán đó: trang được sinh ở build, phục vụ tĩnh, và làm mới ở nền sau mỗi khoảng thời gian.

Giữ được tốc độ của SSG mà không cần build lại. Đổi lại là tính dự đoán được: giữa hai lần revalidate, có người thấy dữ liệu cũ. Không sai — nhưng phải chấp nhận, và phải chọn khoảng thời gian đúng.

6.1. PPR: khi trang vừa tĩnh vừa động

ISR vẫn là “cả trang tĩnh” hoặc “cả trang động”. Nhưng thực tế hầu hết trang là hỗn hợp: header, menu, khung sản phẩm thì tĩnh; giá theo tỷ giá, tồn kho, giỏ hàng thì động.

Trước PPR, bạn chọn một trong hai và chịu hậu quả: hoặc cả trang tĩnh nhưng không cá nhân hoá được, hoặc cả trang động và mất tốc độ tĩnh.

PPR cắt đôi: shell dựng ở build, các lỗ stream ở request.

PPR: shell tĩnh dựng ở build, các lỗ Suspense stream ở request

Shell không đổi giữa các request. Chỉ các lỗ được điền lại.

flowchart LR
    A["BUILD<br/>dựng shell<br/>biết trước vị trí lỗ"] --> B["REQUEST<br/>gửi shell ngay"]
    B --> C["STREAM<br/>mỗi lỗ điền riêng"]

    style A fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style C fill:#1e3f2f,stroke:#34d399,color:#fff

FCP của shell bằng SSG, mà các vùng động vẫn cá nhân hoá được. Cái mất là tính đơn giản. Next 16 còn khá nghiêm khắc: component nào không hoàn tất được ở bước prerender sẽ báo lên dev overlay, chỉ thẳng vào route, kèm ba cách sửa. Nghĩa là nâng cấp sẽ làm vỡ build ở những chỗ trước đây chỉ âm thầm chậm. Đó là chủ ý, nhưng vẫn là việc phải làm.

7. Cache ngầm định thì không đoán được

Bài học đau nhất của Next.js, và mình nghĩ nó xứng đáng một mục riêng.

Next 14 có bốn tầng cache, và tầng nào cũng ngầm định: Request Memoization, Data Cache, Full Route Cache, Router Cache phía client. Bạn phải đoán cái gì đang được cache, và cách duy nhất để biết là thử.

Hệ quả là một loại bug rất khó chịu mà mình đã kể ở đầu bài: dữ liệu trên server đã đúng, nhưng browser vẫn phục vụ payload cũ. Không tầng nào báo lỗi, vì không tầng nào có lỗi — chỉ là một tầng cache đang làm đúng việc của nó ở sai thời điểm.

Next 15 bắt đầu tháo: fetch không cache mặc định, GET route handler không cache mặc định, client router cache không cache mặc định. Ba thay đổi breaking trong một bản minor.

Next 16 đảo ngược hoàn toàn.

Cache: bốn tầng ngầm định cũ, so với use cache tường minh

Cũ: framework đoán. Mới: bạn khai.

async function getProducts() {
  'use cache'
  cacheLife('hours')
  cacheTag('products')
  return db.product.findMany()
}

Mặc định giờ là động. Bạn khai cái cần cache, không phải tắt cái không cần. Đọc code là biết cái gì được cache.

Hai thứ bị lấy đi, và cả hai đáng chú ý:

  • use cache mặc định là in-memory. Chạy ba replica là ba cache riêng biệt. Muốn dùng chung phải khai use cache: remote và cấu hình cache handler. Đây là nguồn của những bug “lúc đúng lúc sai” rất khó tái hiện.
  • Một loạt cấu hình quen tay mất hiệu lực. Bật cacheComponents thì dynamic, dynamicParams, revalidate, fetchCache không còn tác dụng, và experimental_ppr bị xoá. Có codemod, nhưng đây là loại thay đổi phải đọc lại từng route.

7.1. Và vấn đề của chính cơ chế invalidate

Cache có bốn tầng thì cũng có bốn cách làm mới, và chọn sai thì bug lại im lặng.

flowchart TD
    A["Dữ liệu gốc đổi"] --> B{"Đổi từ đâu?"}
    B -->|"trong Server Action,<br/>user đang chờ"| C["updateTag(tag)<br/>read-your-writes"]
    B -->|"webhook, cron,<br/>không ai chờ"| D["revalidateTag(tag, profile)<br/>stale-while-revalidate"]
    B -->|"chỉ cần làm mới<br/>cache phía client"| E["refresh()"]

    style C fill:#1e3f2f,stroke:#34d399,color:#fff
    style D fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style E fill:#3b2f1e,stroke:#f59e0b,color:#fff

Lý do phải có ba API khác nhau, không phải một: hai ngữ cảnh khác nhau cần hai hành vi khác nhau.

Người dùng vừa bấm “lưu” cần thấy dữ liệu mới ngay. Nếu bạn trả cho họ dữ liệu cũ rồi làm mới ở nền, họ sẽ tưởng lưu thất bại. Đó là updateTag.

Webhook từ CMS thì không ai đang chờ. Làm mới ở nền, phục vụ dữ liệu cũ trong lúc đó, là hành vi tốt hơn vì không ai phải chờ. Đó là revalidateTag.

Trường hợp thứ ba: dữ liệu trên server đã đúng, chỉ browser còn giữ payload cũ. Không cần chạm server cache — chỉ cần refresh().

Chú ý chữ ký: ở Next 16, revalidateTag bắt buộc hai tham số. Bản một tham số bị deprecate.

8. Mutation và cái giá của một API endpoint

Trước Server Actions, thêm một sản phẩm vào giỏ cần: viết một route handler, fetch từ client, quản lý useState cho loading/error, rồi tự gọi revalidate sau khi xong. Bốn chỗ, cho một thao tác.

Server Action rút lại thành một thứ:

<form action={addToCart}>
  <button type="submit">Thêm vào giỏ</button>
</form>

Bốn chỗ gộp lại thành một. Quan trọng hơn — nó hoạt động trước khi React hydrate, và cả khi JavaScript bị tắt.

8.1. Vì sao progressive enhancement có được miễn phí

Vì Server Action không phải phép thuật. Nó biên dịch thành một HTTP endpoint thật. Chỉ nhận POST. Action ID nằm trong header Next-Action.

Còn <form action={...}> là một form thật, nên browser submit được nó theo cách cổ điển. Không cần JS. Đó là lý do kỹ thuật để ưu tiên action={} thay vì onClick={() => addToCart()} — không phải sở thích.

sequenceDiagram
    autonumber
    participant C as Client
    participant N as Next server
    C->>N: POST cùng URL trang
    Note over C,N: header Next-Action: action id<br/>body multipart/form-data, chunk Flight
    N->>N: tra action id trong manifest
    N->>N: so Origin với Host — chống CSRF
    N->>N: deserialize, giải mã closure
    N->>N: chạy hàm
    N-->>C: RSC payload mới cho cây vừa đổi

8.2. Cái gì framework lo, cái gì không

Next làm sẵn: chỉ nhận POST, so Origin với Host, action ID không đoán được và xoay giữa các build, closure mã hoá, action không dùng bị loại khỏi bundle client.

Next không làm: xác thực, phân quyền, validate input, rate limit, kiểm soát thứ action trả về.

Hai điểm vi tế đáng nhớ:

  • Mã hoá closure chống sửa đổi, không chống lộ. Nó ngăn client đổi giá trị closure. Nó không biến closure thành chỗ an toàn đặt secret.
  • “Action không dùng bị loại khỏi bundle” chỉ đúng với action không export. Một action được export vẫn còn endpoint, kể cả khi không UI nào gọi.

Nói gọn: đối xử với mỗi Server Action như một API public, vì nó đúng là vậy.

9. Ba vấn đề vận hành

Ba feature còn lại không nằm trong chuỗi logic render. Chúng giải ba vấn đề vận hành khác nhau.

Middleware chạy ở runtime quá hạn chế. Middleware cũ mặc định ở Edge runtime — API hạn chế, không dùng được nhiều thứ của Node. Next 16 đổi tên nó thành proxy.ts và cho mặc định chạy trên Node.js runtime. Lý do Next đưa ra rất thẳng: cái tên middleware nghe như một tầng xử lý trong app, trong khi nó thực chất là chặn ở rìa. proxy mô tả đúng hơn. Lưu ý: runtime config không dùng được trong file proxy nữa — đặt vào sẽ lỗi.

Build chậm. Với monorepo lớn, next build mất hàng chục phút, và vòng lặp dev bị phá. Turbopack giải bằng tính toán tăng dần: mỗi bước biên dịch là hàm thuần của input, cache theo input. Đổi một file thì chỉ bước có input đổi mới chạy lại. Và nó không chỉ là “webpack viết bằng Rust” — nếu vậy thì không có gì để bàn.

Điều hướng tốn băng thông. Một trang 50 link sản phẩm cùng dùng chung một layout: trước đây layout bị tải 50 lần khi prefetch. Next 16 thêm layout deduplication (tải một lần) và incremental prefetching (chỉ tải phần chưa có trong cache, huỷ khi link ra khỏi viewport, ưu tiên khi hover).

10. Chuỗi nhân quả

Nếu bạn chỉ nhớ được một thứ từ bài này, nhớ cái này: mọi feature của Next.js nằm trên một chuỗi nhân quả duy nhất, không phải một danh sách tính năng rời rạc.

flowchart TD
    A["CSR: nội dung chờ JS<br/>3 vòng waterfall"] --> B["SSR: 3 vòng thành 1<br/>nhưng chờ mọi dữ liệu"]
    B --> C["Streaming: Fizz có Task + ping<br/>shell gửi trước, lỗ stream sau"]
    C --> D["Còn vấn đề: vẫn gửi code<br/>của mọi component để hydrate"]
    D --> E["RSC: component chạy ở server<br/>browser nhận kết quả, không nhận code"]
    E --> F["Cần protocol riêng<br/>để serialize cây -> Flight"]
    F --> G["Một ranh giới mới<br/>-> use client, và một CVE"]
    C --> H["Còn vấn đề: tĩnh hay động<br/>là lựa chọn nhị phân"]
    H --> I["PPR: shell tĩnh + lỗ động"]
    I --> J["Cần cache tường minh<br/>-> use cache, cacheLife, cacheTag"]

    style A fill:#3f1e2b,stroke:#fb7185,color:#fff
    style E fill:#2a1e3f,stroke:#a78bfa,color:#fff
    style I fill:#1e3f2f,stroke:#34d399,color:#fff

Đọc từ trên xuống: mỗi mũi tên là một vấn đề mà feature sau sinh ra để giải, và cũng là một vấn đề mới mà feature đó tạo ra.

11. Chọn công cụ: khi nào dùng cái nào

Bảng này là hệ quả của chuỗi trên, không phải một danh sách để học thuộc.

Bạn cầnDùngVì sao
SEO + nội dung đổi liên tụcSSR / streamingNội dung phải có trong HTML đầu
Nhiều vùng dữ liệu tốc độ khác nhauStreaming + Suspense nhiều boundaryVùng nhanh không chờ vùng chậm
Bundle client quá lớnRSC, đẩy use client xuống thấpCode không gửi thì không phải hydrate
Vỏ tĩnh + vài lỗ cá nhân hoáPPRFCP của SSG, dữ liệu động vẫn đúng
Nội dung ít đổi, traffic lớnSSG / ISRKhông render gì cả
App sau đăng nhập, không cần SEOCSRĐơn giản nhất, không cần tầng nào ở trên
Mutation từ formServer ActionProgressive enhancement miễn phí

12. Bảng version và chuyện bảo mật

VersionNgày raĐiểm chính
16.0.02025-10-22Turbopack mặc định, PPR stable, proxy.ts, Cache Components
16.1.02025-12-18Turbopack FS cache cho dev stable, next dev --inspect
16.2.02026-03-18Build Adapters stable
16.3.02026-08-03Bộ nhớ dev giảm tới ~90%; cache Turbopack cho next build bật mặc định
16.4.02026-10-06Bản ổn định mới nhất tại thời điểm viết

Next.js 15 hết vòng đời 21/10/2026 — hai tuần nữa tính từ lúc viết bài này.

Từ tháng 7/2026, Next phát hành security fix theo lịch hàng tháng công bố trước, cộng bản out-of-band cho lỗi nghiêm trọng. Vài mốc gần đây:

  • 16.2.6 (05/2026) — 13 advisory, gồm DoS ở Server Components, bypass proxy qua segment-prefetch, chèn tham số dynamic route, SSRF ở WebSocket upgrade.
  • 16.3.3 (25/08/2026) — hai RCE critical: AVIF image optimization và máy chủ Windows.
  • 16.3.6 (22/09/2026) — RCE critical ở ImageResponse của next/og trên Node.js.
  • 16.3.8 / 15.5.27 (30/09/2026) — chín lỗ hổng: một critical, hai high, năm medium, một low.

Mốc lớn nhất: CVE-2025-55182, công bố ngày 03/12/2025, CVSS 10.0. Gốc rễ là Flight protocol deserialize payload từ client mà không kiểm tra quyền sở hữu thuộc tính — kẻ tấn công chạy được code trên server chỉ bằng một request HTTP dựng tay, không cần xác thực.

Hai chi tiết dễ viết sai về lỗi này:

  • CVE-2025-66478 là cùng một lỗi, không phải lỗi thứ hai. Ban đầu nó được tách riêng cho Next.js, sau đó bị đánh dấu là trùng lặp và huỷ. Đừng đếm nó thành hai advisory.
  • Bản vá không chỉ ở React. React: 19.0.1, 19.1.2, 19.2.1. Next.js: 16.0.7, 15.5.7, 15.4.8, 15.3.6, 15.2.6, 15.1.9, 15.0.5. Nếu bạn chỉ nâng React mà không nâng Next.js (hoặc ngược lại), bạn vẫn hở.

Phạm vi ảnh hưởng không nhỏ: ước lượng khoảng 39% môi trường cloud được quét có workload dính lỗi này, và nó đã được thêm vào danh sách Known Exploited Vulnerabilities của CISA.

Đó là mặt trái của mục 4. Giao thức cho phép browser nói chuyện với server bằng cấu trúc dữ liệu phức tạp cũng là giao thức phải deserialize input không tin cậy. Với RSC, chuyện này sẽ còn lặp lại. Kế hoạch của bạn nên giả định như vậy.

13. Mấy điều mình rút ra

  • Next.js là React cộng một tầng vận hành. Khi bí, quay về câu hỏi: chuyện này thuộc React hay thuộc tầng vận hành? Phần lớn bug nằm ở tầng vận hành, nhưng phần lớn hiểu nhầm nằm ở React.
  • Mọi feature đều là hệ quả của một vấn đề cụ thể. Không nhớ nổi feature nào thì hỏi: nó sinh ra để giải cái gì? Câu trả lời luôn có, và luôn nằm trong chuỗi ở mục 10.
  • Fizz không phải Fiber. Hiểu điều này thì hiểu vì sao streaming khả thi, vì sao Suspense cần tầng dữ liệu biết suspend, và vì sao server không có state để giữ.
  • Streaming không miễn phí, nó đổi chỗ trả. Từ “người dùng chờ” sang “hạ tầng phải biết stream”. Nếu proxy buffer, bạn trả cả hai.
  • RSC không thay SSR. Hiểu lầm tốn thời gian nhất. Client component vẫn SSR ở lần đầu, vẫn hydrate. RSC cắt bớt code, không cắt bớt hydrate.
  • Mặc định của Next 16 là động. Nếu bạn đang thêm cấu hình để tắt cache, có thể bạn đang mang mô hình cũ sang.

Còn một chỗ mình chưa kiểm chứng được: use cache: remote hành xử thế nào khi chạy nhiều region. Trên giấy tờ thì cacheLife quyết định thời gian lan. Thực tế, mình từng gặp độ trễ lan giữa các region dài hơn cả cacheLife đặt ra, và cách xử lý cuối cùng nằm ở hạ tầng chứ không ở code. Mình chưa có số đo để kết luận cái gì.

Ghi chú về nguồn

  • Phần ba renderer là số liệu đếm trực tiếp, không phải suy luận. Cách làm: tải [email protected] từ npm, rồi đếm từng tên hàm bằng grep -o <tên> <file> | wc -l. Con số ở mục 1 chạy lại được — bạn tự đếm sẽ ra đúng vậy.
  • Một lỗi mình đã mắc và sửa khi soát lại bài này: lần đầu mình trộn hai cách đếm khác nhau (grep -c đếm số dòng có chứa, grep -o | wc -l đếm số lần xuất hiện), nên con số không tái lập được. Toàn bộ số trong bài giờ theo một cách đếm duy nhất.
  • Ba package Flight (react-server-dom-webpack, -turbopack, -parcel) đều ở 19.3.0, và chuỗi Flight không xuất hiện trong bất kỳ file nào của package react-dom — cũng là kết quả đếm, không suy từ tài liệu.
  • Số version và ngày phát hành Next.js lấy từ npm registry, không phải từ blog.
  • Hành vi cache, proxy.ts, cacheComponents, use cache, revalidateTag/updateTag/refresh, và việc route segment config bị vô hiệu khi bật Cache Components: theo tài liệu chính thức trên nextjs.org/docs.
  • Các mốc bảo mật (16.2.6, 16.3.3, 16.3.6, 16.3.8) theo thông báo security của Next.js. CVE-2025-55182 theo advisory của React; chi tiết CVE-2025-66478 bị huỷ thành trùng lặp và danh sách bản vá Next.js theo advisory của Unit 42 và Google Cloud.
  • Giới hạn: Next.js phát hành hàng tháng. Mọi hành vi trong bài đúng tại thời điểm viết, nhưng phần cache và prefetching thay đổi nhanh nhất. Con số bộ nhớ của Turbopack ở 16.3 có trong chính bài release của Next; các con số hiệu năng khác trích dẫn từ blog tổng hợp, chưa tự đo lại.
  • Đây là internals, không phải API công khai. React chỉ cam kết giữ ổn định bề mặt API — useState, createRoot, useEffect. Tên file, tên hàm trong reconciler và cấu trúc .next/ có thể đổi bất kỳ bản nào. Dùng để hiểu, đừng xây abstraction lên chúng.
  • Phần cơ chế byte-level của streaming (<!--$?-->, <template id="B:0">, $RC()), hydration và selective hydration mình đã phân tích riêng ở bài SSR.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.