webdev.complete
šŸ—‚ļø Next.js App Router
šŸš€Next.js & T3
Lesson 92 of 117
25 min

Dynamic & Parallel Routes

[slug], catch-all, route groups, parallel and intercepting.

Static routes get you to /about. Real apps need /blog/[slug], /shop/[...path], and modals that open over a list without losing the list's scroll position. Next has primitives for all of that, and they all come from folder naming conventions. No regex. No router config. Just brackets.

[slug]: one dynamic segment

A folder named with square brackets becomes a dynamic segment. Whatever the user types in that position is passed to your page as a parameter.

bash
app/
└── blog/
    └── [slug]/
        └── page.tsx     // matches /blog/anything
params is async in Next.js 16
Starting in Next 15, the params prop is a Promise and must be awaited. Next 16 enforces this strictly. Forget the await and TypeScript will yell; runtime will yell louder.
app/blog/[slug]/page.tsx
type Props = {
  params: Promise<{ slug: string }>;
};

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return <article>{post.title}</article>;
}

Multiple dynamic segments

Stack them. Each [name] folder becomes a key on the params object.

bash
app/users/[userId]/posts/[postId]/page.tsx
// URL: /users/42/posts/7
// params: { userId: "42", postId: "7" }

[...slug]: catch-all routes

Three dots before the name and Next captures the rest of the path as an array. Use this when the depth is variable: docs sites, wikis, file browsers.

bash
app/docs/[...slug]/page.tsx
// /docs/getting-started        params: { slug: ["getting-started"] }
// /docs/api/auth/sign-in       params: { slug: ["api","auth","sign-in"] }
// /docs                         404 (catch-all needs at least one segment)
app/docs/[...slug]/page.tsx
type Props = { params: Promise<{ slug: string[] }> };

export default async function Docs({ params }: Props) {
  const { slug } = await params;
  const path = slug.join("/");
  const doc = await getDoc(path);
  return <article>{doc.content}</article>;
}

[[...slug]]: optional catch-all

Two pairs of brackets and the segment becomes optional. The same page now matches the parent URL too. Handy when you want one file to handle both /docs and /docs/anything/deep.

bash
app/docs/[[...slug]]/page.tsx
// /docs                  params: { slug: undefined }
// /docs/intro            params: { slug: ["intro"] }
// /docs/api/v2/users     params: { slug: ["api","v2","users"] }

Route groups: (parens) for organization

A folder with parentheses around its name is a route group. It does not appear in the URL. You use them to group routes that share a layout or to keep your app/ directory tidy without polluting URLs.

bash
app/
ā”œā”€ā”€ (marketing)/
│   ā”œā”€ā”€ layout.tsx        // shared marketing layout
│   ā”œā”€ā”€ page.tsx          // /
│   └── pricing/page.tsx  // /pricing
ā”œā”€ā”€ (app)/
│   ā”œā”€ā”€ layout.tsx        // shared app shell
│   ā”œā”€ā”€ dashboard/page.tsx  // /dashboard
│   └── settings/page.tsx   // /settings

Two completely different layouts (marketing site vs logged-in app) sit at the same URL level. Without route groups you'd have to pick one root layout and branch inside it.

Multiple root layouts
Each route group can have its own root layout (just put layout.tsx inside the group). When the user crosses between groups, the layout fully reloads. Use this when marketing pages and the app need different fonts, themes, or shells.

Parallel routes: @slot for multiple panes

A folder prefixed with @ is a named slot. The parent layout can render multiple slots side by side, each with its own loading and error states. The classic use case is a dashboard with independent panels.

bash
app/dashboard/
ā”œā”€ā”€ layout.tsx
ā”œā”€ā”€ @analytics/
│   ā”œā”€ā”€ default.tsx
│   └── page.tsx
ā”œā”€ā”€ @team/
│   ā”œā”€ā”€ default.tsx
│   └── page.tsx
└── page.tsx       // the main slot, available as { children }
app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="col-span-2">{children}</div>
      <aside>
        {analytics}
        {team}
      </aside>
    </div>
  );
}
default.tsx is mandatory
Every parallel slot needs a default.tsx. When you navigate to a URL that doesn't have a match for that slot, Next renders default.tsx. Without it, hard refreshes on a sub-route will 404.

Intercepting routes: (.) for modals

Intercepting routes let one route "take over" another for a single navigation. The canonical example: clicking a photo in a feed opens it in a modal, but if a friend visits the URL directly, they see the full photo page. Same URL, two renders.

The convention uses parentheses with dots:

  • (.) match siblings at the same level
  • (..) match one level up
  • (..)(..) match two levels up
  • (...) match from the root
bash
app/
ā”œā”€ā”€ feed/
│   ā”œā”€ā”€ page.tsx              // /feed (list of photos)
│   └── @modal/
│       ā”œā”€ā”€ default.tsx       // null
│       └── (..)photo/[id]/
│           └── page.tsx      // intercepts /photo/[id]
└── photo/
    └── [id]/
        └── page.tsx          // full page at /photo/[id]

From /feed, clicking a photo link to /photo/123 triggers the intercepting route and renders the modal version in the @modal slot. Refreshing the page or sharing the URL bypasses the intercept and shows the full page.

Combining everything

A real e-commerce site might use all of these:

bash
app/
ā”œā”€ā”€ (storefront)/                     // route group: shared layout
│   ā”œā”€ā”€ layout.tsx
│   ā”œā”€ā”€ page.tsx                      // /
│   ā”œā”€ā”€ search/
│   │   ā”œā”€ā”€ @modal/
│   │   │   ā”œā”€ā”€ default.tsx
│   │   │   └── (..)product/[slug]/
│   │   │       └── page.tsx          // quick-view modal
│   │   └── page.tsx                  // /search
│   └── product/[slug]/page.tsx       // /product/anything
└── (admin)/                          // separate root for admins
    ā”œā”€ā”€ layout.tsx
    └── dashboard/[[...path]]/page.tsx // /dashboard or /dashboard/foo/bar

Quiz

Quiz1 / 4

What's the params type in a Next.js 16 dynamic route?

Recap

  • [slug] dynamic, [...slug] catch-all, [[...slug]] optional catch-all.
  • params is now a Promise. Always await params.
  • (group) organizes without affecting URLs.
  • @slot creates parallel routes; each slot needs default.tsx.
  • (.) intercepts let one route render in place of another, perfect for modal overlays that still share URLs.