Next.js 16 from Zero to Senior · Part 3 — Server Components & Data Fetching
Fetch data the App Router way: async Server Components, avoiding request waterfalls with parallel fetching, streaming slow parts with Suspense, the server/client data handoff, and the dynamic-by-default model of Next.js 16.
In the Pages Router (and most React tutorials) data fetching meant useEffect + useState + a loading flag + an API route to hide your secrets {Ở Pages Router (và phần lớn tutorial React) fetch dữ liệu nghĩa là useEffect + useState + cờ loading + một API route để giấu secret}. The App Router deletes all of that {App Router xóa sạch những thứ đó}. This part shows you how data fetching actually works in Next.js 16 — and how to make it fast {Phần này chỉ cho bạn cách fetch dữ liệu thực sự hoạt động ở Next.js 16 — và cách làm nó nhanh}.
1. Fetch in the component, on the server {Fetch ngay trong component, trên server}
Server Components can be async. You await data right where you render it {Server Component có thể async. Bạn await dữ liệu ngay nơi bạn render}:
// app/users/page.tsx (Server Component)
async function getUsers() {
const res = await fetch('https://api.example.com/users');
if (!res.ok) throw new Error('Failed to load users');
return res.json() as Promise<User[]>;
}
export default async function UsersPage() {
const users = await getUsers();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
No useEffect, no useState, no loading flag, no API route {Không useEffect, không useState, không cờ loading, không API route}. The fetch runs on the server during render; the browser receives finished HTML {Fetch chạy trên server lúc render; trình duyệt nhận HTML hoàn chỉnh}.
Direct data sources {Nguồn dữ liệu trực tiếp}
Because the code runs on the server, you can skip HTTP entirely and talk to your database or filesystem directly {Vì code chạy trên server, bạn có thể bỏ qua HTTP hoàn toàn và nói chuyện trực tiếp với database hay filesystem}:
import { db } from '@/lib/db';
export default async function UsersPage() {
const users = await db.user.findMany(); // runs on the server only
return <UserList users={users} />;
}
Your DB credentials never reach the browser {Thông tin đăng nhập DB không bao giờ tới trình duyệt}. This is the single biggest simplification the App Router brings {Đây là sự đơn giản hóa lớn nhất mà App Router mang lại}.
2. Dynamic by default (the Next.js 16 model) {Động theo mặc định (mô hình Next.js 16)}
In Next.js 16 a plain fetch is not cached — it runs on every request, like any backend {Ở Next.js 16 một fetch thường không được cache — nó chạy mỗi request, như bất kỳ backend nào}:
// Fresh on every request. No stale data surprises.
const res = await fetch('https://api.example.com/price');
This is a deliberate reset from Next.js 13–15, where fetches were cached by default and confused everyone {Đây là sự thiết lập lại có chủ ý so với Next.js 13–15, nơi fetch được cache mặc định và làm mọi người bối rối}. The new rule is simple {Quy tắc mới đơn giản}: dynamic unless you opt into caching with 'use cache' (Part 4) {động trừ khi bạn chọn cache bằng 'use cache' (Phần 4)}.
Anything that reads the request also forces dynamic rendering {Bất cứ thứ gì đọc request cũng buộc render động}: cookies(), headers(), searchParams, and params {cookies(), headers(), searchParams, và params}. We use those in Parts 7 and 9 {Ta dùng chúng ở Phần 7 và 9}.
3. The waterfall trap {Bẫy thác nước}
The most common performance mistake is sequential awaits that don’t depend on each other {Lỗi hiệu năng phổ biến nhất là await tuần tự mà không phụ thuộc nhau}:
// ❌ Waterfall: user finishes, THEN posts start. Total = sum of both.
export default async function Profile() {
const user = await getUser(); // 300ms
const posts = await getPosts(); // 300ms → 600ms total
return <Page user={user} posts={posts} />;
}
If two requests are independent, fire them in parallel and await together {Nếu hai request độc lập, bắn chúng song song và await cùng lúc}:
// ✅ Parallel: both start immediately. Total = max of both.
export default async function Profile() {
const [user, posts] = await Promise.all([getUser(), getPosts()]); // ~300ms
return <Page user={user} posts={posts} />;
}
Rule of thumb {Nguyên tắc}: await sequentially only when a later request needs the result of an earlier one {await tuần tự chỉ khi request sau cần kết quả của request trước}. Otherwise, Promise.all {Ngược lại, Promise.all}.
4. Streaming with Suspense {Streaming với Suspense}
You don’t have to make the whole page wait for the slowest query {Bạn không cần bắt cả trang chờ truy vấn chậm nhất}. Wrap a slow component in <Suspense> and Next.js streams the rest of the page immediately, filling the slow hole when it’s ready {Bọc một component chậm trong <Suspense> và Next.js stream phần còn lại của trang ngay, lấp chỗ chậm khi nó sẵn sàng}:
import { Suspense } from 'react';
export default function Dashboard() {
return (
<>
<Header /> {/* fast — renders immediately */}
<Suspense fallback={<RevenueSkeleton />}>
<Revenue /> {/* slow — streams in when ready */}
</Suspense>
</>
);
}
async function Revenue() {
const data = await getRevenue(); // 2s query
return <Chart data={data} />;
}
The key insight {Điểm mấu chốt}: the async work moves into the component inside <Suspense>, not the parent {phần việc async chuyển vào trong component bên trong <Suspense>, không phải ở cha}. The parent stays synchronous and renders instantly {Cha vẫn đồng bộ và render tức thì}.
Time →
0ms [Header][skeleton............] ← user sees layout immediately
2000ms [Header][Revenue chart filled] ← slow part streamed in
This is the foundation of Partial Prerendering (PPR), which we complete in Part 4 {Đây là nền tảng của Partial Prerendering (PPR), ta hoàn thiện ở Phần 4}.
Parallel streaming {Streaming song song}
Multiple Suspense boundaries stream independently — each fills in as soon as its own data is ready {Nhiều ranh giới Suspense stream độc lập — mỗi cái lấp đầy ngay khi dữ liệu của riêng nó sẵn sàng}:
<Suspense fallback={<Sk />}><Revenue /></Suspense>
<Suspense fallback={<Sk />}><Orders /></Suspense>
<Suspense fallback={<Sk />}><Activity /></Suspense>
5. Passing data to Client Components {Truyền dữ liệu sang Client Components}
A Server Component can render a Client Component and pass it data as props {Một Server Component có thể render một Client Component và truyền dữ liệu cho nó qua props}:
// app/page.tsx (Server Component)
import { Chart } from './chart'; // a 'use client' component
export default async function Page() {
const data = await getChartData(); // fetched on the server
return <Chart data={data} />; // passed to the client as props
}
The catch {Lưu ý}: props crossing the server→client boundary must be serializable {props đi qua ranh giới server→client phải serialize được}. You can pass objects, arrays, strings, numbers, dates, even Promises — but not functions, class instances, or things like a DB connection {Bạn có thể truyền object, array, string, number, date, kể cả Promise — nhưng không truyền function, instance của class, hay thứ như kết nối DB}.
The “children” composition pattern {Mẫu “children”}
You can’t import a Server Component into a Client Component, but you can pass one as children {Bạn không thể import một Server Component vào một Client Component, nhưng bạn có thể truyền nó làm children}:
// A client wrapper (e.g. an animated panel)
'use client';
export function Panel({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return open ? <div>{children}</div> : null;
}
// Server Component composes them — ServerData stays on the server
export default function Page() {
return (
<Panel>
<ServerData /> {/* still a Server Component, rendered on the server */}
</Panel>
);
}
This pattern keeps server-rendered content out of the client bundle while still nesting it inside interactive client UI {Mẫu này giữ nội dung render-server ngoài bundle client mà vẫn lồng nó trong UI client tương tác}. Memorize it — it’s how seniors keep bundles small {Hãy nhớ — đây là cách senior giữ bundle nhỏ}.
6. Request memoization {Ghi nhớ request}
If several components in one render need the same data, you don’t need to thread it through props {Nếu nhiều component trong một lần render cần cùng dữ liệu, bạn không cần luồn nó qua props}. React memoizes identical fetch calls within a single render pass, so calling getUser() in three places hits the network once {React ghi nhớ các lời gọi fetch giống hệt trong một lần render, nên gọi getUser() ở ba nơi chỉ chạm mạng một lần}:
// Called in layout AND page — fetched only once per request.
const user = await fetch('https://api.example.com/me').then((r) => r.json());
For non-fetch data sources (e.g. a DB call), wrap the function in React’s cache() to get the same per-request dedup {Với nguồn dữ liệu không phải fetch (vd gọi DB), bọc hàm trong cache() của React để có cùng cơ chế dedup theo request}:
import { cache } from 'react';
import { db } from '@/lib/db';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
Don’t confuse React’s
cache()(per-request dedup, in-memory, no persistence) with Next.js'use cache'(persistent caching across requests, Part 4) {Đừng nhầmcache()của React (dedup theo request, trong bộ nhớ, không lưu lâu) với'use cache'của Next.js (cache lâu dài qua nhiều request, Phần 4)}.
7. Error handling {Xử lý lỗi}
Throwing inside a Server Component bubbles to the nearest error.tsx (Part 2) {Ném lỗi trong một Server Component nổi lên error.tsx gần nhất (Phần 2)}. For expected “not found” cases, call notFound() instead of throwing {Với trường hợp “không tìm thấy” dự kiến, gọi notFound() thay vì ném}:
import { notFound } from 'next/navigation';
export default async function Post({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id);
if (!post) notFound(); // → renders not-found.tsx, sends 404
return <article>{post.body}</article>;
}
Keep try/catch for cases you can recover from (e.g. show a fallback widget); let unrecoverable errors throw to the boundary {Giữ try/catch cho trường hợp có thể phục hồi (vd hiện widget dự phòng); để lỗi không phục hồi được ném lên boundary}.
8. server-only — guard your secrets {server-only — bảo vệ secret}
To guarantee a module never accidentally ends up in a client bundle, import the server-only package at the top {Để đảm bảo một module không bao giờ vô tình lọt vào bundle client, import package server-only ở đầu}:
import 'server-only';
export async function getSecretData() {
return fetch('https://internal', {
headers: { Authorization: `Bearer ${process.env.SECRET}` },
}).then((r) => r.json());
}
If any Client Component imports this module, the build fails with a clear error — a much better outcome than leaking a token {Nếu bất kỳ Client Component nào import module này, build sẽ thất bại với lỗi rõ ràng — kết quả tốt hơn nhiều so với rò rỉ token}. There’s a matching client-only for browser-only code {Có client-only tương ứng cho code chỉ chạy ở trình duyệt}.
9. Exercises {Bài tập}
-
Async page {Page bất đồng bộ}: build
app/users/page.tsxthatawaitshttps://jsonplaceholder.typicode.com/usersand lists names — nouseEffect{dựngapp/users/page.tsxawaitAPI và liệt kê tên — khônguseEffect}. -
Fix a waterfall {Sửa thác nước}: fetch a user and their posts sequentially, measure the time, then convert to
Promise.alland measure again {fetch user và bài viết của họ tuần tự, đo thời gian, rồi đổi sangPromise.allvà đo lại}. -
Stream a slow widget {Stream widget chậm}: add a
<Revenue />component with a 2s artificial delay, wrap it in<Suspense>, and confirm the rest of the page renders instantly {thêm<Revenue />có delay giả 2s, bọc trong<Suspense>, xác nhận phần còn lại render tức thì}. -
Children composition {Mẫu children}: build a
'use client'<Collapsible>and pass a Server Component as itschildren. Verify in the Network tab that the server content’s data fetch didn’t ship to the client {dựng<Collapsible>'use client'và truyền một Server Component làmchildren. Kiểm tra ở tab Network rằng fetch của nội dung server không gửi về client}. -
Dedup {Khử trùng lặp}: call the same
getUser()in both a layout and its page, add aconsole.loginside, and confirm it logs once per request {gọi cùnggetUser()ở cả layout và page, thêmconsole.logbên trong, xác nhận nó log một lần mỗi request}.
What’s next {Phần tiếp theo}
You can fetch data without ceremony, avoid waterfalls, stream slow parts, and keep secrets on the server {Bạn fetch dữ liệu không rườm rà, tránh thác nước, stream phần chậm, và giữ secret trên server}.
Part 4 is the headline of Next.js 16: Cache Components — the 'use cache' directive, cacheLife, cacheTag, and how PPR combines a cached static shell with streamed dynamic holes for instant pages {Phần 4 là điểm nhấn của Next.js 16: Cache Components — directive 'use cache', cacheLife, cacheTag, và cách PPR kết hợp vỏ tĩnh đã cache với các “lỗ” động được stream để có trang tức thì}.