Tiếng Việt

Home

Routing in the App Router: folders, route groups and dynamic routes

The App Router has no route table to declare: the folder tree is the route table. This page builds a small app on next@16.4.4, reads the route table next build prints, then calls each path to see what route groups, nested layouts, dynamic routes and catch-alls actually do. It ends with two build errors word for word, and one mistake that does not fail the build, only serves an empty page.

The code on this page is an app that runs: 49/49 checks passed on 2026-11-07. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.

A folder becomes an address only with page.tsx

Every folder under app/ is a URL segment, but only page.tsx turns that folder into a page. layout.tsx wraps the pages inside and is not a page itself. The sample app has two folders to prove it: chi-co-component holds only a component, chi-co-layout holds only a layout. Neither appears in the route table next build prints, and both answer 404.

Read the outcome in the route table. It has three marks: ○ a static page built at build time, ● a page prerendered from generateStaticParams, ƒ rendered on demand. In the sample app: / is ○, /blog/hai-bai and /blog/ba-bai are ●, and /tai-lieu/[...duong] is ƒ. Do not guess whether a route is static or dynamic — read that line in the build log, because it is also what the server will do at runtime.

Next.js only recognises the file names it defines. A file called trang.tsx does nothing, and a component placed under app/ creates no route. If a URL answers 404 although the folder exists, check that the file is named page.tsx and sits in the right place before looking anywhere else.

app/page.tsx
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>
  );
}

Route groups: round brackets, no slash

(marketing) and (shop) are route groups: they collect pages so those pages share a layout without adding a segment to the URL. /ve-chung-toi sits in (marketing) and carries only the (marketing) frame; /san-pham sits in (shop) and carries only the (shop) frame. Measured on the HTML: the marketing page does not contain the line “Khung (shop)”, and the reverse holds. /marketing/ve-chung-toi answers 404 — the group name is not a path segment.

This is how you split two areas with different chrome (a shop and a content section) without prefixing the URL, and it is also how you put two different layouts at the same folder level, which the bare folder structure would not allow.

What does not work: two groups producing the same path. Add (shop)/ve-chung-toi/page.tsx to the sample app and next build exits 1 with, word for word: You cannot have two parallel pages that resolve to the same path. — followed by Please check /(marketing)/ve-chung-toi and /(shop). The message is useful because it names the two folders in conflict, though it prints the group names rather than the final URL.

app/(marketing)/layout.tsx
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>
  );
}
app/(shop)/layout.tsx
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>
  );
}

Nested layouts: the outer frame wraps the inner one, the page sits inside

Layouts nest along the folder tree. /blog/hai-bai sits inside app/blog/layout.tsx, which sits inside app/layout.tsx. The script measures that order in the returned HTML: “Khung gốc” comes before “Khung blog”, and both come before the article heading. This is the order in the HTML, not the order the functions run in: do not write code that assumes a layout has finished before its page starts. If a frame appears in the wrong place, check this order first.

One practical consequence: a layout at a segment applies to every page beneath it, including dynamic ones. Put the header, menu and footer in a layout near the root, and add a new layout only when a branch genuinely needs different chrome.

And a layout alone is not a route: chi-co-layout has a layout.tsx but no page.tsx, so it does not appear in the route table and answers 404.

app/blog/layout.tsx
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>
  );
}
app/blog/[slug]/page.tsx
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>
  );
}

Dynamic routes: generateStaticParams, and what dynamicParams does

app/blog/[slug]/page.tsx takes any single segment. generateStaticParams returns two slugs, and the route table prints two ● rows: /blog/hai-bai, /blog/ba-bai. app/tag/[ten]/page.tsx works the same way with two tags, and .next/server/app/tag/ holds exactly two prerendered HTML files: nextjs.html, react.html. What generateStaticParams does not list, the build does not produce.

By default, though, those unlisted params still work: dynamicParams is true, so a parameter missing from the list renders when someone asks for it. Measured on /tag/[ten]: /tag/ba — not in generateStaticParams — answers 200 and prints Thẻ: ba. That is the sensible behaviour for a catalogue read from a database: prerender the popular pages, render the rest on demand.

Set export const dynamicParams = false and the behaviour changes while the build still exits 0: /tag/nextjs still answers 200, and /tag/ba answers 404. Choose this for a fixed set of pages such as documentation or legal texts, where an unknown parameter is a mistake and not a new page.

One more precedence detail: app/blog/gioi-thieu/page.tsx is a static folder beside [slug]. /blog/gioi-thieu serves the static page, not the dynamic template — a concrete folder beats a parameter. That is how you keep a hand-written page inside an area served by a dynamic template, without an exception in code.

app/blog/[slug]/page.tsx
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>
  );
}
app/tag/[ten]/page.tsx
/** 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>
  );
}
app/blog/gioi-thieu/page.tsx
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] and [[...duong]]

app/tai-lieu/[...duong]/page.tsx takes one or more segments and collects them into an array. /tai-lieu/a/b/c answers 200 with Catch-all nhận 3 đoạn: a/b/c. But this form needs at least one segment: /tai-lieu answers 404. That is the usual trap when moving a section index onto a catch-all — the section's own landing page disappears.

The optional form [[...duong]] (two pairs of brackets) also matches no segments at all. Rename the folder, adjust one line, and the build still exits 0, the route table prints ƒ /tai-lieu/[[...duong]], and /tai-lieu answers 200 with Catch-all nhận 0 đoạn:. In code, params.duong may be undefined — default it to an empty array.

Which form to use is a question about URLs, not about code: if /tai-lieu is a real page, use [[...duong]]; if it must 404 because there is no index, use [...duong] and keep that behaviour.

app/tai-lieu/[...duong]/page.tsx
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 and notFound()

app/not-found.tsx replaces Next.js's default 404 page for the whole app. Measured on two different paths: a URL that matches no route (/khong-ton-tai), and a page calling notFound() when a parameter is invalid (/blog/khong-co). Both answer with the correct 404 status, and both print the not-found.tsx content.

The status is the part that matters. Answering 200 with the words “not found” is a familiar SEO bug — search engines will index that empty page. In the App Router, notFound() raises a signal and the framework returns a real 404, provided you do not catch that error and answer 200 yourself.

A not-found.tsx inside a subfolder applies to that branch only; one at the root applies to everything. For the sample app one file at the root is enough.

app/not-found.tsx
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>
  );
}

Two build errors, and one green build that is wrong

1. Two route groups on one path: You cannot have two parallel pages that resolve to the same path. — the build exits 1 and names both folders. The fix is to rename one route or merge them.

2. page.tsx and route.ts in the same folder: Conflicting route and page at /san-pham. One path cannot be both a page and an API. The fix: put the Route Handler elsewhere, for example app/api/san-pham/route.ts.

The mistake that does not fail the build is the one to remember. The docs say params is a Promise in Next.js 16 and must be awaited. Do not await it — declare { params: { ten: string } } and read params.ten directly — and next build exits 0, TypeScript finishes with no error, and a prerendered page answers 200 with an empty parameter: <h1>Thẻ: </h1>. A parameter the build did not prerender answers 200 the same way, empty, because dynamicParams is still true. To your users the mistake shows up as “text missing on some pages”, never as a build error.

The fix is always the same: mark the function async, declare params: Promise<{ … }> and write const { … } = await params;. If a dynamic page behaves strangely, check this before suspecting the data.

Three small details that decide a URL

Trailing slash: /san-pham/ answers 308 with location: /san-pham. Next.js does not use trailing slashes by default and redirects permanently to the form without one, so you do not get two URLs for one page.

Upper case: /San-Pham answers 404. Paths are case-sensitive, so a link with the wrong case is a broken link — do not rely on the server to fix it.

One segment or many: [ten] matches exactly one segment, so /tag/a/b answers 404; for several segments you need [...duong]. None of these needs configuration, and yet each is why a “nearly right” URL still fails.

How this page was measured

Every number on this page comes from one script: it copies the sample app to a work directory, installs the pinned versions, builds, runs next start, and repeats with five one-file variants. The check list below is printed from that run.

The result holds for the version named at the top of the page. A new Next.js release does not change this page by itself: we change the pinned version and measure again, and a page whose checks fail is not published.

Checked

  • 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

Measuring script (not public yet): bun scripts/guides/verify-routing.ts

Related

Sources

Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.