Next.js App Router: File-based Routing từ A đến Z
Deep-dive vào routing system của Next.js App Router từ file conventions, nested layouts đến dynamic routes và metadata API.
Nguyễn Nhật Long
@nguyennhatlong1303
Nếu bạn đã quen với Pages Router của Next.js rồi chuyển sang App Router, cảm giác đầu tiên khá là... lạ. Cái folder pages/ quen thuộc biến mất, thay vào đó là một đống file với tên đặc biệt như page.tsx, layout.tsx, loading.tsx. Mình cũng mất khoảng một tuần để thực sự "click" với mental model mới này. Nhưng sau khi hiểu rồi thì phải công nhận cái design này khá elegant.
Bài này mình sẽ đi sâu vào toàn bộ routing system của App Router. Không phải chỉ liệt kê docs, mà là những thứ mình thực sự dùng hàng ngày, kể cả mấy cái bẫy hay gặp.
File conventions "ngôn ngữ" của App Router
App Router hoạt động dựa trên một tập hợp file có tên đặc biệt. Next.js sẽ tự động nhận diện và assign role cho từng file này:
Cái mình thấy hay nhất ở đây là loading.tsx và error.tsx Next.js tự động wrap chúng vào đúng chỗ mà không cần bạn phải tự config <Suspense> hay <ErrorBoundary> thủ công. Trước đây với Pages Router, mình phải tự dựng error boundary, khá là boilerplate.
| File | Vai trò | Ghi chú |
|---|---|---|
| `page.tsx` | UI chính của route | Bắt buộc phải có để route tồn tại |
| `layout.tsx` | Wrapper UI, persist qua navigation | Không re-render khi navigate |
| `loading.tsx` | Suspense fallback tự động | Wrap content trong `<Suspense>` |
| `error.tsx` | Error boundary tự động | Bắt buộc `'use client'` |
| `not-found.tsx` | Custom 404 page | Trigger bằng `notFound()` |
1// app/posts/loading.tsx2// File này tự động được dùng làm Suspense fallback3export default function PostsLoading() {4 return (5 <div className="space-y-4">6 {[...Array(5)].map((_, i) => (7 <div key={i} className="h-20 bg-gray-200 animate-pulse rounded-lg" />8 ))}9 </div>10 );11}
1// app/posts/error.tsx2// 'use client' là bắt buộc error boundaries phải là Client Component3'use client';45export default function PostsError({6 error,7 reset,8}: {9 error: Error;10 reset: () => void;11}) {12 return (13 <div>14 <p>Có lỗi xảy ra: {error.message}</p>15 <button onClick={reset}>Thử lại</button>16 </div>17 );18}
Anh em lưu ý cái reset function đây là cách Next.js cho phép bạn retry mà không cần reload cả trang. Rất tiện khi xử lý lỗi network tạm thời.
Nested Layouts cái thay đổi cách mình nghĩ về UI
Đây là phần mình thấy App Router thực sự vượt trội so với Pages Router. Concept đơn giản: mỗi folder có thể có layout.tsx riêng, và các layout này nest vào nhau.

Ví dụ thực tế: mình đang build một app có cả public blog lẫn admin dashboard. Hai phần này có layout hoàn toàn khác nhau.
1app/2├── layout.tsx # Root layout html, body, global providers3├── (blog)/4│ ├── layout.tsx # Blog layout header, sidebar5│ ├── page.tsx # Trang chủ blog → URL: /6│ └── [slug]/7│ └── page.tsx # Bài viết → URL: /my-post8└── (admin)/9 ├── layout.tsx # Admin layout sidebar khác, auth check10 └── dashboard/11 └── page.tsx # → URL: /dashboard
Cái (blog) và (admin) trong ngoặc tròn là Route Groups chúng nhóm các routes lại với nhau mà không ảnh hưởng đến URL. Folder (blog) không xuất hiện trong URL, nhưng layout.tsx bên trong vẫn được apply. Đây là cách clean nhất để tách biệt layout giữa các phần của app.
Root layout thì bắt buộc phải có và phải chứa <html> và <body>:
1// app/layout.tsx2import { Inter } from 'next/font/google';3import { Providers } from './providers';45const inter = Inter({ subsets: ['latin'] });67export default function RootLayout({8 children,9}: {10 children: React.ReactNode;11}) {12 return (13 <html lang="vi">14 <body className={inter.className}>15 <Providers>{children}</Providers>16 </body>17 </html>18 );19}
Một điểm quan trọng: layout không re-render khi user navigate giữa các pages trong cùng layout. State trong layout được preserve. Đây là lý do tại sao bạn nên đặt những thứ như global state providers, toast notifications, hay sidebar state vào layout thay vì page.
Dynamic Routes từ đơn giản đến phức tạp
Next.js có ba dạng dynamic segments, mỗi dạng phục vụ một use case khác nhau:
Với generateStaticParams, bạn có thể pre-render dynamic routes lúc build time cực kỳ quan trọng cho SEO:
| Syntax | Match | Ví dụ URL |
|---|---|---|
| `[slug]` | Single segment | `/posts/hello-world` |
| `[...slug]` | One or more segments | `/docs/a/b/c` |
| `[[...slug]]` | Zero or more segments | `/docs` hoặc `/docs/a/b` |
1// app/(blog)/[slug]/page.tsx2export async function generateStaticParams() {3 const posts = await fetchAllPosts();45 return posts.map((post) => ({6 slug: post.slug,7 }));8}910export default async function PostPage({11 params,12}: {13 params: { slug: string };14}) {15 const post = await fetchPost(params.slug);1617 if (!post) notFound(); // Trigger not-found.tsx1819 return <article>{/* render post */}</article>;20}
Theo kinh nghiệm của mình, generateStaticParams kết hợp với revalidate là combo tốt nhất cho blog hay content site. Build time pre-render toàn bộ posts, sau đó ISR tự động revalidate khi có bài mới.
Route Handlers API routes theo cách mới
Thay vì pages/api/, giờ bạn dùng route.ts files:
1// app/api/posts/route.ts2import { NextRequest, NextResponse } from 'next/server';34export async function GET(request: NextRequest) {5 const searchParams = request.nextUrl.searchParams;6 const page = searchParams.get('page') ?? '1';78 const posts = await fetchPosts({ page: parseInt(page) });910 return NextResponse.json(posts);11}1213export async function POST(request: NextRequest) {14 const body = await request.json();15 const newPost = await createPost(body);1617 return NextResponse.json(newPost, { status: 201 });18}
Với dynamic route handler:
1// app/api/posts/[id]/route.ts2export async function GET(3 request: NextRequest,4 { params }: { params: { id: string } }5) {6 const post = await fetchPost(params.id);78 if (!post) {9 return NextResponse.json({ error: 'Not found' }, { status: 404 });10 }1112 return NextResponse.json(post);13}
Mình thấy cái này clean hơn Pages Router khá nhiều mỗi HTTP method là một exported function riêng biệt, dễ đọc và dễ maintain hơn cái if (req.method === 'GET') ngày xưa.
Metadata API SEO không còn là nỗi đau
Đây là một trong những feature mình thích nhất của App Router. Metadata được define trực tiếp trong page/layout file, và Next.js tự handle việc inject vào <head>.
Static metadata thì đơn giản:
1// app/(blog)/layout.tsx2export const metadata = {3 title: {4 default: 'My Blog',5 template: '%s | My Blog', // Các trang con sẽ dùng template này6 },7 description: 'Chia sẻ kiến thức về web development',8};
Dynamic metadata cho từng bài viết:
1// app/(blog)/[slug]/page.tsx2export async function generateMetadata({3 params,4}: {5 params: { slug: string };6}) {7 const post = await fetchPost(params.slug);89 return {10 title: post.title, // Render thành "Tên bài viết | My Blog"11 description: post.excerpt,12 openGraph: {13 title: post.title,14 description: post.excerpt,15 images: [post.coverImage],16 },17 };18}
Cái title.template trong layout là một chi tiết nhỏ nhưng rất hay bạn define pattern một lần ở layout, tất cả pages con tự động follow. Không cần copy-paste | My Blog vào từng page nữa.
Navigation Link, useRouter, và redirect()
Có vài cách để navigate trong App Router, mỗi cách có use case riêng:
1import Link from 'next/link';2import { useRouter, usePathname, useSearchParams } from 'next/navigation';3import { redirect } from 'next/navigation';45// 1. Link component cách phổ biến nhất, auto prefetch6<Link href="/posts/hello-world">Đọc bài</Link>78// 2. useRouter programmatic navigation trong Client Component9const router = useRouter();10router.push('/dashboard');11router.replace('/login'); // Không thêm vào history1213// 3. redirect() dùng trong Server Component hoặc Server Action14// Thường dùng cho auth redirect15async function checkAuth() {16 const session = await getSession();17 if (!session) redirect('/login');18}1920// 4. usePathname và useSearchParams đọc current route21const pathname = usePathname(); // '/posts/hello-world'22const searchParams = useSearchParams();23const query = searchParams.get('q');
Anh em lưu ý: useRouter, usePathname, useSearchParams đều là hooks nên chỉ dùng được trong Client Components. Còn redirect() thì dùng được ở cả Server Components và Server Actions đây là cách preferred để handle auth redirect ở server side.
Một điểm nữa về Link component: nó tự động prefetch các routes khi link xuất hiện trong viewport. Đây là lý do navigation trong Next.js app cảm giác nhanh hơn SPA thông thường data đã được fetch trước khi user click.
Routing system của App Router có learning curve nhất định, nhưng khi đã quen rồi thì mình thấy nó giải quyết được rất nhiều vấn đề mà trước đây phải tự dựng. Nested layouts, automatic Suspense boundaries, built-in metadata API những thứ này tiết kiệm cho mình khá nhiều boilerplate code so với cách làm cũ. Bài tiếp theo mình sẽ đi sâu vào Server Components và data fetching patterns phần mà nhiều anh em hay confuse nhất khi mới chuyển sang App Router.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!