Gần đây mình dành thời gian tìm hiểu sâu hơn về SSR và Hydration trong Next.js.
Trước đây mình thường hiểu khá đơn giản:
SSR render HTML ở server → browser nhận HTML → React hydrate → gắn event listener vào HTML đó.
Cách hiểu này không sai, nhưng chưa đủ.
Hydration không đơn giản chỉ là việc “gắn event listener”. React cần khôi phục lại component tree, state, event handling và liên kết logic của ứng dụng để có thể tiếp quản phần DOM mà server đã render.
Và chính vì server và client cùng tham gia vào quá trình render, một vấn đề rất dễ xuất hiện:
Hydration mismatch.
Trong bài viết này, mình muốn đi từ bản chất của CSR/SSR → Hydration → Hydration Mismatch → những lỗi thường gặp → cách xử lý và tối ưu trong một dự án Next.js thực tế.
1. CSR và SSR thực sự khác nhau ở đâu?
Trước tiên hãy bỏ qua React/Next.js và nhìn vào cách browser nhận một website.
CSR — Client-Side Rendering
Với CSR, server thường trả về một HTML gần như chưa có nội dung UI đáng kể:
<div id="root"></div>
<script src="app.js"></script>Sau đó:
Browser
↓
Download HTML
↓
Download JavaScript
↓
Execute React
↓
Render component
↓
Create DOM
↓
User thấy UIVí dụ:
function App() {
return <h1>Hello World</h1>
}Browser phải chạy JavaScript để React tạo ra:
<h1>Hello World</h1>Điều này có nghĩa là nếu JavaScript chưa tải hoặc chưa execute xong, user có thể chưa thấy nội dung thực tế của application.
2. SSR hoạt động khác như thế nào?
Với SSR, React component được render ở server trước.
Ví dụ:
export default function HomePage() {
return <h1>Hello World</h1>
}Server có thể tạo ra HTML:
<h1>Hello World</h1>Browser nhận được HTML này và có thể paint nội dung lên màn hình ngay cả trước khi React hoàn tất việc hydrate.
Flow có thể hình dung:
SERVER
│
React render
│
▼
HTML
│
▼
Browser
│
┌───────┴────────┐
│ │
▼ ▼
Parse HTML Download JS
│ │
▼ ▼
Paint UI Execute React
│
▼
Hydration
│
▼
Interactive UIĐây là một điểm rất quan trọng.
SSR giúp user thấy content sớm, nhưng không có nghĩa là UI interactive ngay lập tức.
3. Tại sao SSR có thể thấy nội dung nhưng chưa bấm được?
Giả sử server render:
<button onClick={handleClick}>
Like
</button>Server có thể gửi xuống:
<button>Like</button>Browser hoàn toàn có thể hiển thị button này.
Nhưng:
<button>Like</button>chỉ là HTML.
Nó chưa tự biết:
handleClick()là gì.
React cần chạy JavaScript ở client và hydrate component để thiết lập lại logic tương tác.
Có thể hình dung:
SSR HTML
↓
User nhìn thấy button
↓
React JS chưa execute
↓
Button chưa interactive
↓
React hydrate
↓
Event handling được thiết lập
↓
Button interactiveDo đó:
Visible ≠ Interactive
Đây là một trong những điểm mình thấy quan trọng nhất khi hiểu SSR.
4. Vậy Hydration thực sự là gì?
Có thể hiểu đơn giản:
Hydration là quá trình React chạy ở client và “tiếp quản” HTML mà server đã render.
Nhưng gọi hydration là:
“React gắn event listener vào HTML”
thì hơi đơn giản hóa quá mức.
React cần dựng lại representation của component tree ở phía client và đối chiếu nó với DOM hiện tại.
Ví dụ component:
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}Server render:
<button>Count: 0</button>Client chạy React và cũng phải có cùng initial result:
<button>Count: 0</button>Sau đó React có thể hydrate component.
Khi user click:
count: 0
↓
click
↓
setCount(1)
↓
React update
↓
<button>Count: 1</button>Điểm quan trọng ở đây là:
Hydration không phải render application từ đầu theo cách CSR thông thường.
React đang cố gắng sử dụng DOM đã có sẵn từ server và kết nối nó với React tree ở client.
5. Hydration giải quyết vấn đề gì?
Nếu chỉ dùng SSR mà không có hydration, chúng ta có thể có HTML rất nhanh:
<h1>Products</h1>
<button>Buy</button>Nhưng application sẽ giống một trang HTML tĩnh.
Không có:
- React state
- event handling
- client-side interaction
- component logic
Hydration giúp biến:
Static HTMLthành:
Interactive React ApplicationDo đó SSR + Hydration thường bổ sung cho nhau:
SSR
↓
Content xuất hiện sớm
Hydration
↓
Application trở nên interactive6. Hydration Mismatch là gì?
Đây là phần bắt đầu thú vị.
Server render:
<h1>Hello Thanh</h1>Nhưng client render:
<h1>Hello Phuoc</h1>Server và client tạo ra UI khác nhau.
Đó chính là hydration mismatch.
Một ví dụ phổ biến:
export default function Component() {
return (
<div>
{new Date().toLocaleTimeString()}
</div>
)
}Server render lúc:
10:00:00Client hydrate lúc:
10:00:03Kết quả:
Server:
10:00:00
Client:
10:00:03React không thể coi đây là cùng một output.
7. Tại sao Hydration Mismatch đáng quan tâm?
Hydration mismatch không chỉ là một warning khó chịu trong console.
Nó cho thấy một vấn đề kiến trúc:
Server và client không có cùng một source of truth cho initial render.
Trong những trường hợp đơn giản, React có thể sửa DOM để khớp với client.
Nhưng mismatch có thể dẫn đến:
- UI bị thay đổi sau hydration
- warning/error trong development
- DOM phải được reconcile lại
- trải nghiệm nhấp nháy
- behavior không như mong đợi
- khó debug hơn
- ảnh hưởng performance nếu xảy ra trên phạm vi lớn
Vì vậy khi sử dụng SSR, một nguyên tắc rất quan trọng là:
Initial render trên server và client nên deterministic và tạo ra cùng UI.
8. localStorage — một nguyên nhân rất phổ biến
Một lỗi rất dễ gặp:
function Theme() {
const theme = localStorage.getItem('theme')
return <div>{theme}</div>
}Vấn đề là:
Server
↓
Không có localStorage
Client
↓
Có localStorageServer không biết browser đang lưu:
theme = darkTrong khi client biết.
Server có thể render:
<div>null</div>Client lại render:
<div>dark</div>→ Mismatch.
9. Cách xử lý localStorage
Một cách phổ biến là chỉ đọc browser API sau khi component mount:
'use client'
function Theme() {
const [theme, setTheme] = useState<string | null>(null)
useEffect(() => {
setTheme(localStorage.getItem('theme'))
}, [])
return <div>{theme}</div>
}Initial render:
Server:
null
Client:
nullSau hydration:
useEffect()
↓
localStorage
↓
dark
↓
setTheme()
↓
re-renderFlow:
SSR
↓
theme = null
↓
Hydration
↓
theme = null
↓
Hydration hoàn tất
↓
useEffect
↓
localStorage
↓
theme = darkĐiểm quan trọng:
useEffect không phải công cụ để hydration chạy.
Hydration xảy ra trước, sau đó effect mới chạy.
Ta sử dụng useEffect để xử lý những logic chỉ tồn tại ở browser.
10. window và document
Một lỗi khác:
const width = window.innerWidthServer không có window.
Hoặc:
document.querySelector(...)Server cũng không có DOM.
Đừng làm browser-only operation trực tiếp trong render.
Thay vào đó:
useEffect(() => {
const width = window.innerWidth
// ...
}, [])Hoặc xử lý thông qua các browser APIs phù hợp.
Nguyên tắc:
Render phase nên càng deterministic càng tốt.
11. Math.random() cũng có thể gây mismatch
Ví dụ:
function Component() {
return <div>{Math.random()}</div>
}Server:
0.1234Client:
0.9876Hai kết quả chắc chắn khác nhau.
Nếu cần một ID hoặc giá trị ổn định, không nên generate random value trực tiếp trong render theo cách khiến server và client tạo ra kết quả khác nhau.
12. Date cũng rất nguy hiểm
Ví dụ:
function Clock() {
return <div>{new Date().toISOString()}</div>
}Server render tại:
10:00:00Client render tại:
10:00:01→ mismatch.
Không chỉ Date.now().
Những logic phụ thuộc vào:
- current time
- timezone
- locale
- browser environment
đều cần cẩn thận.
Ví dụ:
new Date().toLocaleString()có thể cho output khác nhau nếu environment server và browser sử dụng timezone/locale khác nhau.
13. Browser width cũng là một ví dụ kinh điển
Ví dụ:
function Layout() {
const isMobile = window.innerWidth < 768
return isMobile ? (
<MobileLayout />
) : (
<DesktopLayout />
)
}Server không biết:
window.innerWidthNếu cố gắng xử lý trực tiếp trong render, SSR sẽ gặp vấn đề.
Một hướng khác là CSS responsive:
<div className="hidden md:block">
Desktop
</div>
<div className="block md:hidden">
Mobile
</div>Nếu vấn đề chỉ là presentation, hãy để CSS giải quyết thay vì dùng JavaScript.
Đây cũng là một cách giảm client-side logic.
14. DOM manipulation bên ngoài React
Ví dụ:
useEffect(() => {
document.querySelector('#title')!.textContent = 'Hello'
}, [])hoặc một thư viện third-party tự ý thay đổi DOM.
React đang nghĩ:
DOM = Anhưng một thư viện bên ngoài đã biến thành:
DOM = BĐiều này có thể khiến React gặp khó khăn khi reconcile.
Nếu có thể, nên để React quản lý DOM:
const [title, setTitle] = useState('Hello')
return <h1>{title}</h1>thay vì thao tác DOM trực tiếp.
15. "use client" có giải quyết Hydration không?
Không.
Đây là một hiểu nhầm khá phổ biến trong Next.js App Router.
'use client'không có nghĩa:
“Component này không SSR.”
Nó có nghĩa component đó là Client Component và có thể sử dụng client-side features như:
useState
useEffect
onClickClient Components vẫn có thể được render HTML phía server trong quá trình request/build tùy trường hợp.
Sau đó client vẫn cần JavaScript để hydrate.
Vì vậy:
'use client'không đồng nghĩa với:
ssr: falseĐây là hai khái niệm khác nhau.
16. Khi nào dùng dynamic(..., { ssr: false })?
Nếu một component thực sự phụ thuộc hoàn toàn vào browser environment và không cần SSR, có thể sử dụng:
const Chart = dynamic(
() => import('./Chart'),
{
ssr: false,
}
)Điều này phù hợp với những component như:
- một số chart libraries
- editor phụ thuộc DOM
- map libraries
- browser-only third-party libraries
- Web APIs không tồn tại trên server
Flow:
Server
↓
Không render Component
Browser
↓
Download JS
↓
Render ComponentNhưng không nên dùng ssr: false như một cách “né hydration mismatch” cho tất cả mọi thứ.
Nếu component có thể SSR tốt, nên giữ SSR.
17. Đừng biến mọi thứ thành Client Component
Một pattern mình thấy khá quan trọng khi làm Next.js App Router:
'use client'
export default function Page() {
// toàn bộ page trở thành client boundary
}Trong khi thực tế chỉ một button cần interaction:
export default function Page() {
return (
<>
<ServerContent />
<InteractiveButton />
</>
)
}và:
'use client'
export function InteractiveButton() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
)
}Đây là tư duy quan trọng:
Đẩy Client Boundary xuống càng gần nơi cần interactivity càng tốt.
Thay vì:
Page
└── 'use client'
├── Header
├── Content
├── Table
├── Footer
└── ButtonCó thể tổ chức:
Page (Server)
├── Header (Server)
├── Content (Server)
├── Table (Server)
└── Button (Client)Điều này giúp tận dụng Server Components tốt hơn và giảm lượng JavaScript cần gửi/hydrate ở client.
18. SSR Performance không chỉ là “HTML xuất hiện nhanh”
Khi nói về SSR performance, mình nghĩ nên nhìn ít nhất ba giai đoạn:
Server Response
↓
HTML được paint
↓
JavaScript được load
↓
Hydration
↓
InteractiveCó thể có trường hợp:
HTML xuất hiện rất nhanh
↓
Tốtnhưng:
JavaScript bundle quá lớn
↓
Hydration mất nhiều thời gian
↓
User vẫn chưa interact đượcDo đó:
SSR cải thiện tốc độ hiển thị content, nhưng không tự động giải quyết thời gian để application trở nên interactive.
Đây là lý do tối ưu SSR nên đi cùng với tối ưu JavaScript.
19. Server Components giúp gì cho câu chuyện này?
Trong Next.js App Router, Server Components giúp chúng ta giảm nhu cầu đưa logic React xuống browser.
Ví dụ:
export default async function ProductPage() {
const products = await getProducts()
return (
<ProductList products={products} />
)
}Nếu component không cần:
useStateuseEffect- event handlers
- browser APIs
thì không nhất thiết phải biến nó thành Client Component.
Có thể để server xử lý.
Khi đó browser không cần hydrate những phần không cần interactivity theo cách một Client Component cần.
Tư duy có thể đơn giản hóa thành:
Server Component
→ Server xử lý
Client Component
→ Browser cần JavaScript
→ Có hydration/client interactivityVì vậy khi tối ưu Next.js:
Không phải cứ SSR là đủ. Hãy quan tâm cả lượng JavaScript mà client phải nhận và hydrate.
20. Một pattern thực tế: dữ liệu server + interaction client
Ví dụ trang product:
export default async function ProductPage() {
const product = await getProduct()
return (
<div>
<ProductInfo product={product} />
<AddToCartButton productId={product.id} />
</div>
)
}ProductInfo có thể là Server Component:
function ProductInfo({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
</div>
)
}Chỉ AddToCartButton cần client:
'use client'
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
// add to cart
}
return (
<button onClick={handleClick}>
{loading ? 'Adding...' : 'Add to cart'}
</button>
)
}Đây là một pattern rất thực tế:
Server
├── Fetch data
├── Render content
└── Generate HTML
Client
└── Only interactive parts21. Vậy khi nào nên dùng useEffect?
useEffect phù hợp khi logic phụ thuộc vào browser hoặc side effect.
Ví dụ:
useEffect(() => {
const handler = () => {
// browser event
}
window.addEventListener('resize', handler)
return () => {
window.removeEventListener('resize', handler)
}
}, [])Hoặc:
useEffect(() => {
const theme = localStorage.getItem('theme')
setTheme(theme)
}, [])Nhưng không nên có tư duy:
“Có hydration mismatch → nhét hết code vào useEffect.”
Hãy hỏi trước:
Logic này có thực sự cần browser không?
Nếu không:
Để server render.
Nếu chỉ là presentation:
Dùng CSS.
Nếu thực sự browser-dependent:
Đưa logic sang client lifecycle.
22. Một số cách xử lý không nên lạm dụng
Khi gặp hydration warning, đôi khi developer sẽ nghĩ ngay tới việc suppress warning hoặc tắt SSR.
Ví dụ các cơ chế suppress mismatch có thể hữu ích trong một số trường hợp đặc biệt, nhưng không nên được dùng để che giấu nguyên nhân.
Tư duy tốt hơn là:
Hydration mismatch
↓
Tìm nguyên nhân
↓
Server output khác Client output ở đâu?
↓
Loại bỏ sự khác biệt nếu có thểthay vì:
Hydration mismatch
↓
Suppress warning
↓
DoneWarning biến mất không có nghĩa vấn đề đã biến mất.
23. Checklist Hydration cho Next.js
Khi làm SSR/Next.js, mình thường có thể tự kiểm tra theo checklist này:
Rendering
- Server và client tạo ra cùng initial UI
- Không sử dụng random value trực tiếp trong render
- Không sử dụng current time trực tiếp trong render
- Cẩn thận với timezone/locale
Browser APIs
- Không đọc
windowtrong server render - Không đọc
documenttrong server render - Không đọc
localStoragetrong server render - Browser-only logic được xử lý ở client lifecycle
Client Components
- Không thêm
"use client"ở quá cao trong component tree - Client Boundary càng nhỏ càng tốt
- Chỉ component cần interaction mới trở thành Client Component
Third-party libraries
- Kiểm tra library có tương thích SSR không
- Browser-only library có thể dùng
ssr: falsekhi cần - Tránh để third-party library tự ý manipulate DOM nếu không cần thiết
Performance
- Giảm JavaScript bundle
- Tận dụng Server Components
- Không hydrate những UI không cần interaction
- Lazy load những component nặng khi phù hợp
24. Một mental model mình thấy dễ nhớ
Sau khi tìm hiểu, mình thấy có thể nhớ SSR + Hydration bằng một flow rất đơn giản:
SERVER
│
▼
Render React Tree
│
▼
HTML
│
▼
Browser
│
┌───────┴────────┐
│ │
▼ ▼
Paint HTML Download JS
│
▼
React runs
│
▼
Hydration
│
▼
Interactive UIVà có một nguyên tắc quan trọng:
Server Render
↓
≈
Client Initial RenderNếu hai bên khác nhau:
Server UI
≠
Client UI
↓
Hydration Mismatch25. Kết luận
Sau khi hiểu sâu hơn về Hydration, mình thấy cách nhìn về SSR cũng thay đổi khá nhiều.
SSR không đơn giản là:
“Render HTML ở server để SEO tốt hơn.”
Và Hydration cũng không đơn giản là:
“Gắn event listener vào HTML.”
Có thể nhìn toàn bộ quá trình như:
SSR
↓
Tạo initial HTML nhanh
↓
Browser paint content
↓
Client tải JavaScript
↓
React hydrate
↓
Application trở nên interactiveTrong đó, Hydration là cầu nối giữa HTML được server render và React application chạy ở browser.
Và vì cả server lẫn client đều tham gia vào initial rendering, chúng ta phải đặc biệt chú ý tới những thứ có thể tạo ra output khác nhau:
localStorage
window
document
Date
Math.random()
timezone
locale
browser environment
DOM manipulationCuối cùng, khi tối ưu một ứng dụng Next.js, mình nghĩ không nên chỉ hỏi:
“Có dùng SSR chưa?”
Mà nên hỏi thêm:
“Phần nào thực sự cần chạy ở client?”
“Có bao nhiêu JavaScript cần được hydrate?”
“Server và client có tạo ra cùng initial UI không?”
“Client Boundary của mình có đang quá lớn không?”
Đó mới là góc nhìn giúp SSR và Hydration trở thành một phần của performance architecture, thay vì chỉ là một feature của framework.