jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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: paramssearchParams giờ 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ề usePathnameuseSearchParams ở Phần 5.


12. Bài tập

  1. Bài động: dựng app/blog/[slug]/page.tsx await params và render slug. Vào /blog/hello/blog/world.

  2. Layout lồng: thêm app/dashboard/layout.tsx có sidebar, cùng page.tsxsettings/page.tsx. Đặt một counter useState trong widget sidebar 'use client' và xác nhận nó giữ giá trị khi chuyển tab.

  3. Trạng thái loading: thêm loading.tsx vào /dashboard và một await delay giả trong page. Quan sát skeleton hiện tức thì.

  4. Route group: tách app thành nhóm (marketing)(app) với hai layout khác nhau, giữ URL //dashboard sạch.

  5. 404: trong route blog động, gọi notFound() khi slug không phải hello hay world, và thêm not-found.tsx.

  6. Nâng cao — parallel routes: biến dashboard thành slot @analytics + @team, mỗi cái có loading.tsx riê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.