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.
In Part 1 you learned that a folder is a route {Ở Phần 1 bạn đã học một thư mục là một route}. That’s the easy 20%. This part is the other 80% that makes routing in the App Router genuinely powerful {Đó 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}: dynamic data, nested layouts, parallel UI, modal interception, and the per-segment loading/error states that make an app feel instant {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. Static routes & nested folders {Route tĩnh & thư mục lồng}
Folders nest into URL paths {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
A folder without a page.tsx is not routable — it’s just an organizational container or a place for a layout.tsx {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. Dynamic segments {Segment động}
Wrap a folder name in square brackets to capture a URL part {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
The captured value arrives via params — and in Next.js 16, params is a Promise you must await {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>;
}
Next.js 16 breaking change {Thay đổi phá vỡ ở Next.js 16}:
paramsandsearchParamsare now async. Accessing them synchronously throws an error {paramsvàsearchParamsgiờ là bất đồng bộ. Truy cập đồng bộ sẽ ném lỗi}. This lets Next.js stream the static parts of a page before the dynamic params resolve {Đ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 {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>;
}
The single-bracket catch-all ([...slug]) requires at least one segment {Catch-all một ngoặc ([...slug]) yêu cầu ít nhất một segment}; the double-bracket optional version ([[...slug]]) also matches the bare parent route {phiên bản optional hai ngoặc ([[...slug]]) khớp cả route cha trống}.
3. searchParams — query strings {searchParams — chuỗi truy vấn}
Page components also receive searchParams (also a Promise) {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>;
}
Reading searchParams makes a page dynamic (it depends on the request) {Đọc searchParams làm một trang trở nên động (nó phụ thuộc request)}. We’ll see in Part 4 how to keep a fast static shell around it with PPR {Ta sẽ thấy ở Phần 4 cách giữ một vỏ tĩnh nhanh quanh nó bằng PPR}.
4. Layouts: shared, nested, stateful {Layout: dùng chung, lồng nhau, giữ state}
A layout.tsx wraps every page beneath it and persists across navigations — it does not re-render or lose state when you move between sibling pages {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>
);
}
Layouts nest: navigating /dashboard/settings renders root layout → dashboard layout → settings page {Layout lồng nhau: vào /dashboard/settings render root layout → dashboard layout → settings page}:
<RootLayout>
<DashboardLayout>
<SettingsPage />
</DashboardLayout>
</RootLayout>
Because the sidebar lives in the layout, switching dashboard tabs keeps scroll position, focus, and any component state in the sidebar {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: when you want a remount {Template: khi bạn muốn mount lại}
A template.tsx is like a layout but creates a new instance on every navigation {template.tsx giống layout nhưng tạo một instance mới mỗi lần điều hướng}. Use it when you want enter-animations to replay or state to reset per page {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
Reach for layout 95% of the time; template only for that specific reset behavior {Dùng layout 95% thời gian; template chỉ cho hành vi reset đặc thù đó}.
5. Loading & streaming with loading.tsx {Loading & streaming với loading.tsx}
Drop a loading.tsx into a segment and Next.js automatically wraps that segment’s page in a <Suspense> boundary, showing the loading UI instantly while the server renders {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>;
}
This is the magic behind “instant navigation” {Đây là phép màu sau “điều hướng tức thì”}: the user sees the layout + skeleton immediately, then the slow data streams in {user thấy layout + skeleton ngay, rồi dữ liệu chậm stream vào}. You can also place <Suspense> manually around any slow component for finer control (Part 3) {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. Errors & 404s per segment {Lỗi & 404 theo segment}
error.tsx is a React error boundary scoped to its segment — it must be a Client Component because error boundaries need state {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 renders when you call notFound() from server code or hit an unmatched route {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 groups & private folders {Route group & thư mục riêng tư}
Wrap a folder in parentheses to group routes without affecting the URL {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
This lets you give different sections different root-level layouts while keeping clean URLs {Đ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}. A folder prefixed with _ (e.g. _components) is a private folder — never routable, good for colocating helpers {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 {Parallel routes}
Named slots (folders starting with @) let a single layout render multiple pages simultaneously {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
The layout receives each slot as a prop {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>
</>
);
}
Each slot streams and errors independently — @analytics can show its own loading.tsx while @team is already done {Mỗi slot stream và lỗi độc lập — @analytics có thể hiện loading.tsx riêng trong khi @team đã xong}. This is ideal for dashboards where widgets load at different speeds {Lý tưởng cho dashboard nơi các widget tải ở tốc độ khác nhau}.
9. Intercepting routes (modals done right) {Intercepting routes (làm modal đúng cách)}
Intercepting routes let you show a route’s content in the current layout (e.g. a modal) when navigated to from within the app, while a full page load of the same URL shows the standalone page {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}.
The convention uses (.) to intercept the same level {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)
Markers: (.) same level, (..) one level up, (..)(..) two levels, (...) from the root {Ký hiệu: (.) cùng cấp, (..) lên một cấp, (..)(..) hai cấp, (...) từ gốc}. Combined with a @modal parallel slot, this is how production apps build shareable, refresh-safe modals (the Instagram photo-modal pattern) {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. Static params with generateStaticParams {Tham số tĩnh với generateStaticParams}
For dynamic routes you know at build time (blog posts, docs), pre-render them by exporting 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 builds one static HTML file per returned param at build time {Next.js build một file HTML tĩnh cho mỗi param trả về lúc build}. This is the App Router equivalent of the old getStaticPaths {Đây là tương đương App Router của getStaticPaths cũ}.
11. Navigating between routes {Điều hướng giữa các route}
Use <Link> for declarative navigation — it prefetches in the background for instant transitions {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>
);
}
For programmatic navigation inside a Client Component, use the useRouter hook from next/navigation (not next/router, which is the old Pages Router) {Để đ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>;
}
We’ll cover usePathname and useSearchParams in Part 5 {Ta sẽ nói về usePathname và useSearchParams ở Phần 5}.
12. Exercises {Bài tập}
-
Dynamic post {Bài động}: build
app/blog/[slug]/page.tsxthatawaitsparamsand renders the slug. Visit/blog/helloand/blog/world{dựngapp/blog/[slug]/page.tsxawaitparamsvà render slug. Vào/blog/hellovà/blog/world}. -
Nested layout {Layout lồng}: add
app/dashboard/layout.tsxwith a sidebar, pluspage.tsxandsettings/page.tsx. Put auseStatecounter in a'use client'sidebar widget and confirm it keeps its value when switching tabs {thêmapp/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}. -
Loading state {Trạng thái loading}: add a
loading.tsxto/dashboardand an artificialawait new Promise(r => setTimeout(r, 1500))in the page. Watch the skeleton appear instantly {thêmloading.tsxvào/dashboardvà mộtawaitdelay giả trong page. Quan sát skeleton hiện tức thì}. -
Route groups {Route group}: split your app into
(marketing)and(app)groups with two different layouts, keeping/and/dashboardURLs clean {tách app thành nhóm(marketing)và(app)với hai layout khác nhau, giữ URL/và/dashboardsạch}. -
404 {404}: in the dynamic blog route, call
notFound()when the slug isn’thelloorworld, and add anot-found.tsx{trong route blog động, gọinotFound()khi slug không phảihellohayworld, và thêmnot-found.tsx}. -
Stretch — parallel routes {Nâng cao — parallel routes}: turn the dashboard into
@analytics+@teamslots, each with its ownloading.tsx, and watch them stream independently {biến dashboard thành slot@analytics+@team, mỗi cái cóloading.tsxriêng, và xem chúng stream độc lập}.
What’s next {Phần tiếp theo}
You can now express almost any URL structure: dynamic, nested, grouped, parallel, intercepted — with per-segment loading and error states {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}.
Part 3 dives into Server Components and data fetching — how to await data directly, avoid request waterfalls, and stream slow parts with <Suspense> so your pages feel fast without a single useEffect {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}.