Next.js 16 from Zero to Senior · Part 2 — Routing Deep Dive
Master App Router routing: dynamic and catch-all segments, route groups, parallel and intercepting routes, layouts vs templates, loading/error/not-found, generateStaticParams, and the async params/searchParams of Next.js 16.
Ở Phần 1 bạn đã học một thư mục là một route. Đó là 20% dễ. Phần này là 80% còn lại biến routing trong App Router thành thứ thực sự mạnh: dữ liệu động, layout lồng, UI song song, chặn modal, và các trạng thái loading/error theo segment giúp app cảm giác tức thì.
1. Route tĩnh & thư mục lồng
Thư mục lồng vào thành đường dẫn URL:
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── dashboard/
├── page.tsx → /dashboard
└── settings/
└── page.tsx → /dashboard/settings
Một thư mục không có page.tsx thì không truy cập được — nó chỉ là vùng chứa để tổ chức hoặc nơi đặt layout.tsx.
2. Segment động
Bọc tên thư mục trong ngoặc vuông để bắt một phần của URL:
app/blog/[slug]/page.tsx → /blog/anything
Giá trị bắt được tới qua params — và ở Next.js 16, params là một Promise bạn phải await:
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <h1>Post: {slug}</h1>;
}
Thay đổi phá vỡ ở Next.js 16:
paramsvàsearchParamsgiờ là bất đồng bộ. Truy cập đồng bộ sẽ ném lỗi. Điều này cho phép Next.js stream phần tĩnh của trang trước khi params động được giải quyết.
Catch-all & optional catch-all
app/docs/[...slug]/page.tsx → /docs/a, /docs/a/b, /docs/a/b/c
app/shop/[[...filters]]/page.tsx → /shop AND /shop/red/small
// app/docs/[...slug]/page.tsx
export default async function Docs({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params; // e.g. ['guides', 'routing']
return <p>Path depth: {slug.length}</p>;
}
Catch-all một ngoặc ([...slug]) yêu cầu ít nhất một segment; phiên bản optional hai ngoặc ([[...slug]]) khớp cả route cha trống.
3. searchParams — chuỗi truy vấn
Page component cũng nhận searchParams (cũng là Promise):
// app/search/page.tsx → /search?q=nextjs&page=2
export default async function Search({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q = '', page = '1' } = await searchParams;
return <p>Searching "{q}" on page {page}</p>;
}
Đọc searchParams làm một trang trở nên động (nó phụ thuộc request). Ta sẽ thấy ở Phần 4 cách giữ một vỏ tĩnh nhanh quanh nó bằng PPR.
4. Layout: dùng chung, lồng nhau, giữ state
layout.tsx bọc mọi page bên dưới và tồn tại qua các lần điều hướng — nó không re-render hay mất state khi bạn di chuyển giữa các page anh em.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[200px_1fr]">
<nav>{/* sidebar — stays mounted across /dashboard/* */}</nav>
<section>{children}</section>
</div>
);
}
Layout lồng nhau: vào /dashboard/settings render root layout → dashboard layout → settings page:
<RootLayout>
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
</RootLayout>
Vì sidebar nằm trong layout, chuyển tab dashboard vẫn giữ vị trí cuộn, focus, và mọi state component trong sidebar.
Template: khi bạn muốn mount lại
template.tsx giống layout nhưng tạo một instance mới mỗi lần điều hướng. Dùng khi bạn muốn animation vào lặp lại hoặc state reset theo từng trang.
layout.tsx → persists state, runs effects once
template.tsx → fresh state, re-runs effects on each navigation
Dùng layout 95% thời gian; template chỉ cho hành vi reset đặc thù đó.
5. Loading & streaming với loading.tsx
Bỏ một loading.tsx vào segment và Next.js tự động bọc page của segment đó trong một <Suspense>, hiện UI loading tức thì trong khi server render:
// app/dashboard/loading.tsx
export default function Loading() {
return <p>Loading dashboard…</p>;
}
Đây là phép màu sau “điều hướng tức thì”: user thấy layout + skeleton ngay, rồi dữ liệu chậm stream vào. Bạn cũng có thể đặt <Suspense> thủ công quanh bất kỳ component chậm nào để kiểm soát mịn hơn (Phần 3).
6. Lỗi & 404 theo segment
error.tsx là một error boundary của React phạm vi trong segment — nó bắt buộc là Client Component vì error boundary cần state:
// app/dashboard/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div role="alert">
<p>Something broke: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
not-found.tsx render khi bạn gọi notFound() từ code server hoặc vào một route không khớp:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // renders the nearest not-found.tsx, returns 404
return <article>{post.title}</article>;
}
7. Route group & thư mục riêng tư
Bọc thư mục trong ngoặc đơn để gom route mà không ảnh hưởng URL:
app/
├── (marketing)/
│ ├── layout.tsx # layout for marketing pages only
│ ├── page.tsx → / (NOT /marketing)
│ └── pricing/page.tsx → /pricing
└── (app)/
├── layout.tsx # a DIFFERENT layout for the app
└── dashboard/page.tsx → /dashboard
Điều này cho phép gán cho các khu vực khác nhau layout cấp gốc khác nhau mà vẫn giữ URL sạch. Thư mục bắt đầu bằng _ (vd _components) là thư mục riêng tư — không bao giờ thành route, tốt để đặt helper cạnh nhau.
8. Parallel routes
Các slot có tên (thư mục bắt đầu bằng @) cho phép một layout render nhiều page cùng lúc:
app/dashboard/
├── layout.tsx
├── page.tsx # the @children slot (implicit)
├── @analytics/page.tsx
└── @team/page.tsx
Layout nhận mỗi slot như một prop:
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<>
{children}
<div className="grid grid-cols-2">
{analytics}
{team}
</div>
</>
);
}
Mỗi slot stream và lỗi độc lập — @analytics có thể hiện loading.tsx riêng trong khi @team đã xong. Lý tưởng cho dashboard nơi các widget tải ở tốc độ khác nhau.
9. Intercepting routes (làm modal đúng cách)
Intercepting route cho phép hiện nội dung của một route trong layout hiện tại (vd modal) khi điều hướng từ trong app, còn khi tải nguyên URL đó thì hiện trang độc lập.
Quy ước dùng (.) để chặn cùng cấp:
app/
├── feed/page.tsx
├── photo/[id]/page.tsx # standalone photo page (direct visit / refresh)
└── feed/
└── (.)photo/[id]/page.tsx # modal version (soft navigation from the feed)
Ký hiệu: (.) cùng cấp, (..) lên một cấp, (..)(..) hai cấp, (...) từ gốc. Kết hợp với một parallel slot @modal, đây là cách app production làm modal chia sẻ được, an toàn khi refresh (kiểu modal ảnh của Instagram).
10. Tham số tĩnh với generateStaticParams
Với route động bạn biết lúc build (bài blog, docs), pre-render chúng bằng cách export generateStaticParams:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
Next.js build một file HTML tĩnh cho mỗi param trả về lúc build. Đây là tương đương App Router của getStaticPaths cũ.
11. Điều hướng giữa các route
Dùng <Link> cho điều hướng khai báo — nó prefetch ngầm để chuyển tức thì:
import Link from 'next/link';
export default function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/blog/hello">First post</Link>
</nav>
);
}
Để điều hướng bằng code trong một Client Component, dùng hook useRouter từ next/navigation (không phải next/router của Pages Router cũ):
'use client';
import { useRouter } from 'next/navigation';
export function LogoutButton() {
const router = useRouter();
return <button onClick={() => router.push('/login')}>Log out</button>;
}
Ta sẽ nói về usePathname và useSearchParams ở Phần 5.
12. Bài tập
-
Bài động: dựng
app/blog/[slug]/page.tsxawaitparamsvà render slug. Vào/blog/hellovà/blog/world. -
Layout lồng: thêm
app/dashboard/layout.tsxcó sidebar, cùngpage.tsxvàsettings/page.tsx. Đặt một counteruseStatetrong widget sidebar'use client'và xác nhận nó giữ giá trị khi chuyển tab. -
Trạng thái loading: thêm
loading.tsxvào/dashboardvà mộtawaitdelay giả trong page. Quan sát skeleton hiện tức thì. -
Route group: tách app thành nhóm
(marketing)và(app)với hai layout khác nhau, giữ URL/và/dashboardsạch. -
404: trong route blog động, gọi
notFound()khi slug không phảihellohayworld, và thêmnot-found.tsx. -
Nâng cao — parallel routes: biến dashboard thành slot
@analytics+@team, mỗi cái cóloading.tsxriêng, và xem chúng stream độc lập.
Phần tiếp theo
Giờ bạn có thể diễn đạt gần như mọi cấu trúc URL: động, lồng, nhóm, song song, chặn — với loading và error theo từng segment.
Phần 3 đi sâu vào Server Components và data fetching — cách await dữ liệu trực tiếp, tránh “thác” request, và stream phần chậm bằng <Suspense> để trang nhanh mà không cần một useEffect nào.