Routing trong App Router: thư mục, nhóm tuyến và tuyến động
App Router không có bảng route để khai báo: cây thư mục là bảng route. Trang này dựng một ứng dụng nhỏ trên next@16.4.4, đọc bảng tuyến mà next build in ra, rồi gọi thử từng đường để xem nhóm tuyến, layout lồng nhau, tuyến động và catch-all thật sự làm gì. Cuối cùng là hai lỗi build nguyên văn, và một lỗi không làm build đỏ mà chỉ làm trang trả về rỗng.
Mã trên trang này là một ứng dụng chạy được: 49/49 kiểm tra đạt ngày 07/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.
Một thư mục chỉ thành địa chỉ khi có page.tsx
Mỗi thư mục trong app/ là một đoạn URL, nhưng chỉ page.tsx biến thư mục đó thành một trang. layout.tsx bọc các trang bên trong và tự nó không phải trang. Ứng dụng mẫu có hai thư mục để chứng minh điều này: chi-co-component chỉ chứa một component, chi-co-layout chỉ chứa một layout. Cả hai đều không xuất hiện trong bảng tuyến của next build và đều trả 404.
Đọc kết quả ở bảng tuyến. Bảng có ba ký hiệu: ○ là trang tĩnh dựng lúc build, ● là trang dựng sẵn từ generateStaticParams, ƒ là render theo yêu cầu. Trong ứng dụng mẫu: / là ○, /blog/hai-bai và /blog/ba-bai là ●, còn /tai-lieu/[...duong] là ƒ. Đừng đoán một tuyến là tĩnh hay động — đọc đúng dòng đó trong log build, vì đó cũng là thứ máy chủ sẽ làm lúc chạy.
Next.js chỉ nhận đúng những tên tệp nó quy định. trang.tsx không có tác dụng gì, và một component đặt trong app/ cũng không tạo ra đường nào. Nếu một URL trả 404 dù thư mục đã có, kiểm tra xem tệp có đúng tên page.tsx và đúng vị trí hay không — trước khi nghĩ tới chuyện khác.
export default function Home() {
return (
<main>
<h1>Routing trong App Router</h1>
<ul>
<li>
<a href="/ve-chung-toi">/ve-chung-toi</a> — nằm trong nhóm <code>(marketing)</code>
</li>
<li>
<a href="/san-pham">/san-pham</a> — nằm trong nhóm <code>(shop)</code>
</li>
<li>
<a href="/blog/hai-bai">/blog/hai-bai</a> — tuyến động, dựng sẵn từ <code>generateStaticParams</code>
</li>
<li>
<a href="/tai-lieu/nextjs/app-router">/tai-lieu/nextjs/app-router</a> — catch-all
</li>
</ul>
<p>Trang chủ nằm ngay trong <code>app/page.tsx</code>.</p>
</main>
);
}Nhóm tuyến: tên trong ngoặc tròn, không vào URL
(marketing) và (shop) là nhóm tuyến: chúng gom các trang lại để dùng chung một layout mà không thêm đoạn nào vào URL. /ve-chung-toi nằm trong (marketing) và chỉ mang khung của (marketing); /san-pham nằm trong (shop) và chỉ mang khung của (shop). Đo trên HTML: trang marketing không có dòng “Khung (shop)”, và ngược lại. Còn /marketing/ve-chung-toi trả 404 — tên nhóm không phải một đoạn đường.
Đây là cách tách hai khu vực có giao diện khác nhau (trang bán hàng và trang nội dung) mà không phải thêm tiền tố vào URL, và cũng là cách đặt hai layout khác nhau ở cùng một cấp thư mục — điều mà cấu trúc thư mục thường không cho phép.
Cái không làm được: hai nhóm cùng cho ra một đường. Thêm (shop)/ve-chung-toi/page.tsx vào ứng dụng mẫu thì next build thoát 1 với nguyên văn: You cannot have two parallel pages that resolve to the same path. — kèm dòng Please check /(marketing)/ve-chung-toi and /(shop). Câu này hữu ích vì nó chỉ đúng hai thư mục đang tranh nhau, nhưng lại in tên nhóm chứ không in đường dẫn cuối.
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<nav>Khung (marketing): chỉ các trang trong nhóm này thấy dòng này</nav>
{children}
</section>
);
}export default function ShopLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<nav>Khung (shop): chỉ các trang trong nhóm này thấy dòng này</nav>
{children}
</section>
);
}Layout lồng nhau: khung ngoài bọc khung trong, trang nằm trong cùng
Layout lồng theo cây thư mục. /blog/hai-bai nằm trong app/blog/layout.tsx, và layout đó nằm trong app/layout.tsx. Script đo thứ tự ấy trên HTML trả về: “Khung gốc” đứng trước “Khung blog”, và cả hai đứng trước tiêu đề bài viết. Đây là thứ tự trong HTML, không phải thứ tự chạy của các hàm: mã của bạn không nên giả định layout chạy xong rồi trang mới chạy. Nếu một khung xuất hiện sai chỗ, hãy kiểm tra thứ tự này trước.
Một hệ quả thực dụng: layout ở một đoạn áp cho mọi trang bên dưới, kể cả trang động. Đặt phần đầu trang, menu và chân trang ở layout gần gốc, và chỉ tạo layout mới khi một nhánh thật sự cần khung khác.
Và một layout không tự tạo route: chi-co-layout có layout.tsx nhưng không có page.tsx, nên nó không có mặt trong bảng tuyến và trả 404.
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<nav>Khung blog: bọc mọi trang con của /blog</nav>
{children}
</section>
);
}import { notFound } from "next/navigation";
/** Hai bài có sẵn; bài khác không tồn tại. */
const BAI = ["hai-bai", "ba-bai"];
export function generateStaticParams() {
return BAI.map((slug) => ({ slug }));
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!BAI.includes(slug)) notFound();
return (
<main>
<h1>Bài viết: {slug}</h1>
<p>Trang này do mẫu động [slug] render.</p>
</main>
);
}Tuyến động: generateStaticParams, và dynamicParams làm gì
app/blog/[slug]/page.tsx nhận một đoạn bất kỳ. generateStaticParams trả về hai slug, và bảng tuyến in ra hai dòng ●: /blog/hai-bai, /blog/ba-bai. app/tag/[ten]/page.tsx cũng vậy, với hai thẻ, và .next/server/app/tag/ có đúng hai tệp HTML dựng sẵn: nextjs.html, react.html. Những gì generateStaticParams không liệt kê thì build không dựng.
Nhưng mặc định chúng vẫn chạy được: dynamicParams là true, nên một tham số không có trong danh sách sẽ được render khi có người hỏi. Đo trên /tag/[ten]: /tag/ba — không nằm trong generateStaticParams — trả 200 và in đúng Thẻ: ba. Đây là hành vi hợp lý cho một danh mục lấy từ cơ sở dữ liệu: dựng sẵn những trang phổ biến, phần còn lại render khi cần.
Đặt export const dynamicParams = false thì hành vi đổi hẳn, dù build vẫn thoát 0: /tag/nextjs vẫn 200, còn /tag/ba trả 404. Chọn cách này cho một tập trang cố định như tài liệu hay văn bản pháp lý, vì ở đó một tham số lạ là lỗi chứ không phải một trang mới.
Về thứ tự ưu tiên: app/blog/gioi-thieu/page.tsx là thư mục tĩnh nằm cạnh [slug], và /blog/gioi-thieu trả về trang tĩnh chứ không phải mẫu động. Thư mục cụ thể thắng tham số, nên bạn giữ được một trang viết tay trong khu vực có mẫu động mà không cần ngoại lệ trong code.
import { notFound } from "next/navigation";
/** Hai bài có sẵn; bài khác không tồn tại. */
const BAI = ["hai-bai", "ba-bai"];
export function generateStaticParams() {
return BAI.map((slug) => ({ slug }));
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!BAI.includes(slug)) notFound();
return (
<main>
<h1>Bài viết: {slug}</h1>
<p>Trang này do mẫu động [slug] render.</p>
</main>
);
}/** Hai thẻ có sẵn, và không có kiểm tra tồn tại: câu hỏi là Next.js làm gì với thẻ thứ ba. */
const THE = ["nextjs", "react"];
export function generateStaticParams() {
return THE.map((ten) => ({ ten }));
}
export default async function Tag({ params }: { params: Promise<{ ten: string }> }) {
const { ten } = await params;
return (
<main>
<h1>Thẻ: {ten}</h1>
<p>Trang này không kiểm tra thẻ có tồn tại hay không.</p>
</main>
);
}export default function Intro() {
return (
<main>
<h1>Trang tĩnh gioi-thieu thắng mẫu [slug]</h1>
<p>Đoạn này chỉ có trong trang tĩnh cùng cấp, không có trong blog/[slug]/page.tsx.</p>
</main>
);
}Catch-all: [...duong] và [[...duong]]
app/tai-lieu/[...duong]/page.tsx nhận một hoặc nhiều đoạn và gom chúng thành một mảng. /tai-lieu/a/b/c trả 200 với Catch-all nhận 3 đoạn: a/b/c. Nhưng dạng này cần ít nhất một đoạn: /tai-lieu trả 404. Đó là cái bẫy hay gặp khi chuyển một mục lục sang catch-all — trang gốc của mục lục biến mất.
Dạng tuỳ chọn [[...duong]] (hai cặp ngoặc) khớp cả trường hợp không có đoạn nào. Đổi tên thư mục và sửa một dòng, build vẫn thoát 0, bảng tuyến in ƒ /tai-lieu/[[...duong]], và /tai-lieu trả 200 với Catch-all nhận 0 đoạn:. Trong code, params.duong có thể là undefined — hãy mặc định về mảng rỗng.
Chọn dạng nào là câu hỏi về URL, không phải về code: nếu /tai-lieu là một trang thật thì dùng [[...duong]]; nếu nó phải 404 vì không có mục lục, dùng [...duong] và giữ nguyên hành vi đó.
export default async function Docs({ params }: { params: Promise<{ duong: string[] }> }) {
const { duong } = await params;
return (
<main>
<h1>Tài liệu</h1>
<p>{`Catch-all nhận ${duong.length} đoạn: ${duong.join("/")}`}</p>
</main>
);
}404: not-found.tsx và notFound()
app/not-found.tsx thay trang 404 mặc định của Next.js cho toàn ứng dụng. Đo trên hai đường khác nhau: một URL không khớp route nào (/khong-ton-tai), và một trang gọi notFound() khi tham số không hợp lệ (/blog/khong-co). Cả hai trả đúng mã 404, và cả hai in nội dung của not-found.tsx.
Mã trạng thái mới là phần quan trọng. Trả 200 kèm chữ “không tìm thấy” là lỗi SEO quen thuộc — công cụ tìm kiếm sẽ index trang rỗng đó. Ở App Router, notFound() ném ra một tín hiệu và framework trả 404 thật, miễn là bạn không bắt lỗi đó rồi tự trả 200.
Một not-found.tsx đặt trong một thư mục con chỉ áp cho nhánh đó; đặt ở gốc thì áp cho tất cả. Với ứng dụng mẫu, một tệp ở gốc là đủ.
import Link from "next/link";
export default function NotFound() {
return (
<main>
<h1>Trang này không có</h1>
<p>Đây là app/not-found.tsx — 404 tự viết, không phải trang mặc định của Next.js.</p>
<Link href="/">Về trang chủ</Link>
</main>
);
}Hai lỗi build, và một lỗi build vẫn xanh
1. Hai nhóm tuyến cùng một đường: You cannot have two parallel pages that resolve to the same path. — build thoát 1, và lỗi chỉ ra hai thư mục. Cách sửa là đổi tên một trong hai route hoặc gộp chúng lại.
2. page.tsx và route.ts trong cùng một thư mục: Conflicting route and page at /san-pham. Một đường không thể vừa là trang vừa là API. Cách sửa: đặt Route Handler ở thư mục khác, ví dụ app/api/san-pham/route.ts.
Lỗi không làm build đỏ là lỗi đáng nhớ nhất. Tài liệu nói params là một Promise trong Next.js 16 và phải await. Nếu không await — khai báo { params: { ten: string } } và đọc thẳng params.ten — thì next build thoát 0, TypeScript chạy xong không báo lỗi, và trang dựng sẵn trả 200 với tham số rỗng: <h1>Thẻ: </h1>. Một tham số không được dựng sẵn cũng trả 200 với phần rỗng như vậy, vì dynamicParams vẫn là true. Với người dùng, lỗi hiện ra như “mất chữ trên vài trang”, không bao giờ như một lỗi build.
Cách sửa luôn giống nhau: đánh dấu hàm async, khai báo params: Promise<{ … }> và const { … } = await params;. Nếu một trang động có hành vi lạ, đây là thứ đầu tiên cần kiểm tra — trước cả khi nghi ngờ dữ liệu.
Ba chi tiết nhỏ quyết định URL
Dấu gạch chéo cuối: /san-pham/ trả 308 với location: /san-pham. Next.js mặc định không dùng dấu chéo cuối và chuyển hướng vĩnh viễn về dạng không có nó, nên bạn không có hai URL cho một trang.
Chữ hoa: /San-Pham trả 404. Đường dẫn phân biệt chữ hoa chữ thường, nên một liên kết viết sai hoa là một liên kết hỏng — đừng dựa vào việc máy chủ tự sửa.
Một đoạn hay nhiều đoạn: [ten] chỉ khớp đúng một đoạn, nên /tag/a/b trả 404; muốn nhiều đoạn thì phải dùng [...duong]. Ba chi tiết này không cần cấu hình gì, nhưng chúng là lý do một URL “gần đúng” vẫn hỏng.
Cách đo trang này
Mọi con số trên trang đến từ một script: chép ứng dụng mẫu vào một thư mục làm việc, cài đúng phiên bản đã ghim, build, chạy next start, rồi lặp lại với năm biến thể một tệp. Danh sách phép đo bên dưới in từ kết quả của lần chạy đó.
Kết quả chỉ đúng với phiên bản ghi ở đầu trang. Next.js ra bản mới thì trang không tự đổi: chúng tôi phải đổi phiên bản đã ghim và đo lại, và phép đo nào không đạt thì trang không được lên site.
Đã kiểm tra
- next build exits 0:
0 - a folder with a page.tsx is a route, and a folder with only a component file is not:
absent - a folder with only a layout.tsx is not a route either:
absent - the home page is prerendered at build time:
○ / - generateStaticParams prerendered both slugs of /blog/[slug]:
● hai-bai, ● ba-bai - generateStaticParams prerendered both names of /tag/[ten]:
● nextjs, ● react - a catch-all route with no generateStaticParams is dynamic, not prerendered:
ƒ /tai-lieu/[...duong] - the route group (marketing) does not appear in the URL the build prints:
0 occurrences - only the two listed tag names exist as prerendered HTML; a third is not built:
nextjs.html, react.html - the home page answers 200:
200 - a page inside the (marketing) group answers at its bare path:
200 - the (marketing) group layout renders on that page:
present - the (shop) group layout does not render on a (marketing) page:
absent - the other group keeps its own layout:
Khung (shop) - the group name is not a URL segment: /marketing/ve-chung-toi:
404 - a prerendered dynamic page answers 200:
200 - the layouts nest in the HTML: root outside, then blog, then the page:
root < blog < page - a static folder beside [slug] wins that one URL:
Trang tĩnh gioi-thieu thắng mẫu [slug] - notFound() in a page answers 404, not 200:
404 - notFound() renders the app's own not-found.tsx:
present - a param missing from generateStaticParams still answers 200: dynamicParams is true by default:
200 - and it renders the real param:
Thẻ: ba - a single [param] folder does not match two segments:
404 - a catch-all gets every segment:
Catch-all nhận 3 đoạn: a/b/c - a catch-all needs at least one segment, so /tai-lieu itself is 404:
404 - a URL no route matches answers 404:
404 - and it renders the custom app/not-found.tsx, not Next.js's default:
present - a trailing slash is a 308, not a second URL:
308 /san-pham - the match is case-sensitive: /San-Pham:
404 - the component-only folder answers 404:
404 - dynamicParams = false: next build still exits 0:
0 - dynamicParams = false: a listed param still answers 200:
200 - dynamicParams = false: an unlisted param answers 404:
404 - optional catch-all [[...duong]]: next build exits 0:
0 - optional catch-all: the route table names the double-bracket form:
ƒ /tai-lieu/[[...duong]] - optional catch-all: the folder itself answers 200:
200 - optional catch-all: with no segments it renders an empty list:
Catch-all nhận 0 đoạn: - two route groups on the same path: next build exits 1:
1 - two route groups on the same path: the error names the rule:
You cannot have two parallel pages that resolve to the same path. Please check /(marketing)/ve-chung-toi and /(shop). - page.tsx and route.ts in one folder: next build exits 1:
1 - page.tsx and route.ts in one folder: the error names the path:
Conflicting route and page at /san-pham: route at /(shop)/san-pham/route and page at /(shop)/san-ph - params read synchronously: next build exits 0:
0 - params read synchronously: TypeScript finishes without an error:
Finished TypeScript - params read synchronously: the prerendered page answers 200:
200 - params read synchronously: the parameter is silently empty:
<h1>Thẻ: </h1> - params read synchronously: an unlisted param still answers 200 (dynamicParams is true), and the parameter is empty:
200 <h1>Thẻ: </h1> - params read synchronously with dynamicParams = false: next build exits 0:
0 - params read synchronously with dynamicParams = false: an unlisted param answers 404, not the page:
404 <h1>Trang này không có</h1> - every variant file is restored byte for byte after its build:
restored
Script đo (chưa công khai): bun scripts/guides/verify-routing.ts
Liên quan
- Server Component và Client Component: cái gì chạy ở đâu
- Cài đặt Next.js 16 và tạo dự án đầu tiên
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- Next.js docs — Layouts and pages
- Next.js docs — Route groups
- Next.js docs — Dynamic route segments
- Next.js docs — generateStaticParams
- Next.js docs — not-found file
- Next.js docs — route.ts (Route Handlers)
- Next.js docs — page.tsx
- npm registry — next dist-tags
Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.