Phân tích
7 phút đọc15 tháng 6, 2026281

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.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Next.js App Router: File-based Routing từ A đến Z

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.tsxerror.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.

FileVai tròGhi chú
`page.tsx`UI chính của routeBắt buộc phải có để route tồn tại
`layout.tsx`Wrapper UI, persist qua navigationKhông re-render khi navigate
`loading.tsx`Suspense fallback tự độngWrap content trong `<Suspense>`
`error.tsx`Error boundary tự độngBắt buộc `'use client'`
`not-found.tsx`Custom 404 pageTrigger bằng `notFound()`
TSX
1// app/posts/loading.tsx
2// File này tự động được dùng làm Suspense fallback
3export 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}
TSX
1// app/posts/error.tsx
2// 'use client' là bắt buộc error boundaries phải là Client Component
3'use client';
4
5export 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.

Next.js App Router nested layouts diagram

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.

TEXT
1app/
2├── layout.tsx # Root layout html, body, global providers
3├── (blog)/
4│ ├── layout.tsx # Blog layout header, sidebar
5│ ├── page.tsx # Trang chủ blog → URL: /
6│ └── [slug]/
7│ └── page.tsx # Bài viết → URL: /my-post
8└── (admin)/
9 ├── layout.tsx # Admin layout sidebar khác, auth check
10 └── dashboard/
11 └── page.tsx # → URL: /dashboard

Cái (blog)(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><body>:

TSX
1// app/layout.tsx
2import { Inter } from 'next/font/google';
3import { Providers } from './providers';
4
5const inter = Inter({ subsets: ['latin'] });
6
7export 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:

SyntaxMatchVí 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`
TSX
1// app/(blog)/[slug]/page.tsx
2export async function generateStaticParams() {
3 const posts = await fetchAllPosts();
4
5 return posts.map((post) => ({
6 slug: post.slug,
7 }));
8}
9
10export default async function PostPage({
11 params,
12}: {
13 params: { slug: string };
14}) {
15 const post = await fetchPost(params.slug);
16
17 if (!post) notFound(); // Trigger not-found.tsx
18
19 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:

TSX
1// app/api/posts/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function GET(request: NextRequest) {
5 const searchParams = request.nextUrl.searchParams;
6 const page = searchParams.get('page') ?? '1';
7
8 const posts = await fetchPosts({ page: parseInt(page) });
9
10 return NextResponse.json(posts);
11}
12
13export async function POST(request: NextRequest) {
14 const body = await request.json();
15 const newPost = await createPost(body);
16
17 return NextResponse.json(newPost, { status: 201 });
18}

Với dynamic route handler:

TSX
1// app/api/posts/[id]/route.ts
2export async function GET(
3 request: NextRequest,
4 { params }: { params: { id: string } }
5) {
6 const post = await fetchPost(params.id);
7
8 if (!post) {
9 return NextResponse.json({ error: 'Not found' }, { status: 404 });
10 }
11
12 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:

TSX
1// app/(blog)/layout.tsx
2export const metadata = {
3 title: {
4 default: 'My Blog',
5 template: '%s | My Blog', // Các trang con sẽ dùng template này
6 },
7 description: 'Chia sẻ kiến thức về web development',
8};

Dynamic metadata cho từng bài viết:

TSX
1// app/(blog)/[slug]/page.tsx
2export async function generateMetadata({
3 params,
4}: {
5 params: { slug: string };
6}) {
7 const post = await fetchPost(params.slug);
8
9 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:

TSX
1import Link from 'next/link';
2import { useRouter, usePathname, useSearchParams } from 'next/navigation';
3import { redirect } from 'next/navigation';
4
5// 1. Link component cách phổ biến nhất, auto prefetch
6<Link href="/posts/hello-world">Đọc bài</Link>
7
8// 2. useRouter programmatic navigation trong Client Component
9const router = useRouter();
10router.push('/dashboard');
11router.replace('/login'); // Không thêm vào history
12
13// 3. redirect() dùng trong Server Component hoặc Server Action
14// Thường dùng cho auth redirect
15async function checkAuth() {
16 const session = await getSession();
17 if (!session) redirect('/login');
18}
19
20// 4. usePathname và useSearchParams đọc current route
21const 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.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễ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è!

Next.js App Router: File-based Routing từ A đến Z — Stacklog