App Router trong Next.js: Layout, Page và Route
1. Tổng quan
App Router là cơ chế định tuyến mặc định của Next.js từ bản 13 trở đi, thay thế dần Pages Router (pages/). Nó dựa trên quy ước file trong thư mục app/: page.tsx tạo route, layout.tsx tạo UI dùng chung, route.ts tạo API endpoint. Đọc xong bài này, bạn sẽ biết cách tổ chức một dự án Next.js theo App Router, viết layout lồng nhau, tạo dynamic route và route handler, cùng cách test các component này.
2. Cấu trúc thư mục cơ bản
App Router dùng folder = segment URL. Mỗi folder trong app/ có thể chứa file đặc biệt quyết định hành vi route.
app/
├── layout.tsx # layout gốc, bắt buộc có <html>, <body>
├── page.tsx # route "/"
├── about/
│ └── page.tsx # route "/about"
├── products/
│ ├── layout.tsx # layout riêng cho /products/*
│ ├── page.tsx # route "/products"
│ └── [id]/
│ └── page.tsx # route "/products/123"
└── api/
└── orders/
└── route.ts # API "/api/orders"
layout.tsxbọc lấy mọipage.tsxcùng cấp hoặc sâu hơn.page.tsxlà điểm cuối thực sự render nội dung cho một URL.route.tskhông render UI, nó trả vềResponsegiống REST endpoint.
Lưu ý: Tên folder không bắt đầu bằng chữ cái thường (ví dụ
Products) vẫn hoạt động nhưng Next.js khuyến nghị dùng kebab-case hoặc camelCase thống nhất để tránh nhầm lẫn trên hệ điều hành phân biệt hoa thường.
3. Page — định nghĩa route
File page.tsx export một React component làm UI chính cho route. Mặc định nó là Server Component.
// app/products/page.tsx
import { ProductService } from "@/services/product-service";
export default async function ProductsPage() {
const products = await ProductService.findAll();
return (
<main>
<h1>Danh sách sản phẩm</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name} - {product.price}đ</li>
))}
</ul>
</main>
);
}
async function ProductsPagecho phépawaitdữ liệu trực tiếp trong component vì đây là Server Component, không cầnuseEffecthaygetServerSideProps.- Next.js tự render HTML phía server, gửi về trình duyệt đã có sẵn nội dung — tốt cho SEO và thời gian hiển thị đầu tiên.
- Route tương ứng với URL
/products.
3.1. Dynamic route với params
// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";
type ProductPageProps = {
params: { id: string };
};
export default async function ProductDetailPage({ params }: ProductPageProps) {
const product = await ProductService.findById(params.id);
if (!product) {
notFound();
}
return <h1>{product.name}</h1>;
}
- Folder
[id]tạo segment động, Next.js bơm giá trị thực tế vàoparams.id. notFound()render filenot-found.tsxgần nhất thay vì crash app — đây là cách xử lý 404 chuẩn trong App Router.
4. Layout — UI dùng chung
Layout nhận prop children là route con, giữ nguyên state khi chuyển trang giữa các page cùng layout (ví dụ sidebar không re-render).
// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="products-layout">
<aside>Bộ lọc sản phẩm</aside>
<section>{children}</section>
</div>
);
}
childrenở đây là nội dung củaapp/products/page.tsxhoặcapp/products/[id]/page.tsx.- Khi điều hướng từ
/productssang/products/123, phần<aside>không bị unmount/remount — đây là lợi ích chính của layout so với việc tự import component dùng chung trong mỗi page.
4.1. Root layout là bắt buộc
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Shop Nón Lá",
description: "Cửa hàng demo App Router",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body>
<header>Shop Nón Lá</header>
{children}
</body>
</html>
);
}
- Root layout phải chứa
<html>và<body>vì nó là layout cao nhất, bọc toàn bộ ứng dụng. export const metadatalà cách khai báo<title>,<meta>theo kiểu khai báo, Next.js tự inject vào<head>— không cần thao tác DOM thủ công.
Lưu ý: Nếu bạn quên tạo
app/layout.tsx, Next.js sẽ báo lỗi build ngay vì mọi route đều cần ít nhất root layout.
5. Route Groups và Parallel Segments
Đôi khi bạn muốn nhóm route để tổ chức code nhưng không muốn nhóm đó xuất hiện trên URL. Dùng route group với cặp dấu ngoặc đơn (name).
app/
├── (marketing)/
│ ├── layout.tsx # layout riêng cho trang marketing
│ ├── page.tsx # route "/"
│ └── pricing/
│ └── page.tsx # route "/pricing"
└── (dashboard)/
├── layout.tsx # layout riêng cho dashboard
└── settings/
└── page.tsx # route "/settings"
(marketing)và(dashboard)không xuất hiện trong URL, chúng chỉ giúp áp layout khác nhau cho từng nhóm trang./pricingvà/settingsdùng layout khác nhau dù cùng nằm dướiapp/.
6. Route Handlers — xây API trong App Router
File route.ts thay thế pages/api/*.ts của Pages Router, export các hàm theo tên HTTP method.
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET() {
const orders = await OrderService.findAll();
return NextResponse.json(orders);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const order = await OrderService.create(body);
return NextResponse.json(order, { status: 201 });
}
GETvàPOSTlà hai export riêng biệt, Next.js tự route request theo method HTTP đến đúng hàm.NextResponse.json(...)setContent-Type: application/jsontự động, bạn không cần tựJSON.stringify.- Endpoint này phục vụ tại
/api/orders, hoàn toàn độc lập với UI — không render React.
6.1. Dynamic route handler
// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET(_: Request, { params }: { params: { id: string } }) {
const order = await OrderService.findById(params.id);
if (!order) {
return NextResponse.json({ message: "Order not found" }, { status: 404 });
}
return NextResponse.json(order);
}
- Giống dynamic page, folder
[id]cho phép lấy giá trị từ URL quaparams.id. - Trả
status: 404rõ ràng thay vì để client tự đoán lỗi.
7. File đặc biệt: loading, error, not-found
App Router dùng React Suspense và Error Boundary ngầm qua các file quy ước, bạn không cần tự viết boundary.
// app/products/loading.tsx
export default function ProductsLoading() {
return <p>Đang tải danh sách sản phẩm...</p>;
}
// app/products/error.tsx
"use client";
export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>Lỗi: {error.message}</p>
<button onClick={reset}>Thử lại</button>
</div>
);
}
loading.tsxtự hiển thị trong khipage.tsxcủa cùng segment đangawaitdữ liệu — không cầnuseState(isLoading).error.tsxphải là Client Component ("use client") vì nó dùngreset— một hàm tương tác phía client để render lại segment bị lỗi.- Next.js tự bọc segment bằng
<Suspense>và error boundary tương ứng dựa trên các file này.
8. Metadata và layout lồng nhau
Mỗi segment có thể export metadata riêng, Next.js merge chúng theo thứ tự từ layout gốc xuống page.
// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";
type Props = { params: { id: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await ProductService.findById(params.id);
return { title: product ? `${product.name} - Shop Nón Lá` : "Không tìm thấy" };
}
export default async function ProductDetailPage({ params }: Props) {
const product = await ProductService.findById(params.id);
return <h1>{product?.name}</h1>;
}
generateMetadatalà hàm động, cho phép đặt<title>dựa trên dữ liệu thực tế của sản phẩm — hữu ích cho SEO trang chi tiết.- Metadata của page con override field cùng tên từ layout cha, các field khác vẫn giữ nguyên.
9. Test page component
Với Server Component bất đồng bộ, cách đơn giản nhất để test là gọi trực tiếp hàm component và kiểm tra JSX trả về, hoặc dùng React Testing Library sau khi render.
<!-- package.json (phần devDependencies, rút gọn) -->
<dependencies>
<vitest>^2.0.0</vitest>
<testing-library-react>^16.0.0</testing-library-react>
</dependencies>
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";
vi.mock("@/services/product-service", () => ({
ProductService: {
findAll: vi.fn(),
},
});
describe("ProductsPage", () => {
it("hiển thị danh sách sản phẩm trả về từ service", async () => {
(ProductService.findAll as any).mockResolvedValue([
{ id: "1", name: "Nón lá A", price: 50000 },
]);
const jsx = await ProductsPage();
render(jsx);
expect(screen.getByText("Nón lá A - 50000đ")).toBeInTheDocument();
});
});
ProductsPage()được gọi như hàm async thuần vì bản chất nó chỉ là một function trả về JSX — không cần framework test đặc biệt cho Server Component.vi.mockthayProductService.findAllbằng stub trả dữ liệu giả, giúp test không phụ thuộc database thật.render(jsx)từ Testing Library kiểm tra JSX đã render ra DOM đúng nội dung mong đợi.
10. Lỗi hay gặp và khi nào không nên dùng
- Quên
"use client"khi dùng hook nhưuseState/useEffect: Server Component không hỗ trợ các hook này, Next.js sẽ báo lỗi build rõ ràng. - Đặt logic fetch dữ liệu nhạy cảm (API key) trong Client Component: vì code sẽ bị bundle xuống browser. Luôn ưu tiên fetch trong Server Component hoặc Route Handler.
- Lạm dụng route group chỉ để “cho đẹp”: nếu không có nhu cầu layout khác nhau thật sự, route group chỉ làm cây thư mục rối hơn.
- Trộn
pages/vàapp/cho cùng một route: hai router không thể cùng xử lý một URL, Next.js sẽ ưu tiênapp/và route trongpages/bị bỏ qua — dễ gây nhầm lẫn khi migrate dần.
Lưu ý: Nếu dự án của bạn đang chạy ổn với Pages Router và không cần Server Components, Streaming hay Server Actions, việc migrate ngay sang App Router không phải lúc nào cũng cần thiết. Hãy đánh giá lợi ích trước khi đổi toàn bộ routing.
11. Kết luận
App Router tổ chức ứng dụng Next.js qua ba loại file chính: page.tsx định nghĩa route, layout.tsx chia sẻ UI giữa nhiều route, và route.ts xây API không render HTML. Kết hợp với các file quy ước như loading.tsx, error.tsx, not-found.tsx, bạn có thể xây trải nghiệm loading/error mượt mà mà không cần tự viết boundary. Khi thiết kế cây thư mục, hãy ưu tiên nhóm theo layout thực sự cần chia sẻ, tránh route group thừa và luôn kiểm tra ranh giới Server/Client Component để không rò rỉ logic nhạy cảm ra browser.