How to set up dynamic routes in Next.js. Full breakdown of [[...slug]]

How to set up dynamic routes in Next.js. Full breakdown of [[...slug]]

Dynamic routes in the App Router provide flexibility for building a blog, catalog, store, or documentation site. Basic use cases can be handled with [id] and [...slug], but when it comes to sections with optional nesting, the most convenient architecture is enabled by [[...slug]].

In this article, we’ll cover all three patterns, but focus primarily on [[...slug]]: how it matches URLs, what arrives in params, how to type it safely, how to generate static paths, how to build breadcrumbs, and how to handle SEO.

Quick Route Map

  • [slug] — one segment Example: /blog/my-post params.slug: string
  • [...slug] — required catch-all (at least one segment is needed) Example: /docs/getting-started/install params.slug: string[]
  • [[...slug]] — optional catch-all (matches both the root and any number of segments) Examples: /shop, /shop/men, /shop/men/t-shirts params.slug: string[] | undefined

When to Use [[...slug]]

  • One component and layout for both the root and all nested levels of a section. Example: documentation or a catalog, where the main section page and subpages share the same structure.
  • You need a root page without segments, plus unlimited nesting below. Example: /docs as a table of contents, and deeper paths like /docs/guides/setup/cloud.
  • Breadcrumbs and navigation are built from an array of segments, but the root page also needs to work.

Basic Implementation of [[...slug]]

Directory structure:

app/
  docs/
    [[...slug]]/
      page.tsx
      layout.tsx

page.tsx:

import { notFound } from 'next/navigation';

type PageProps = {
  params: { slug?: string[] };
};

async function getNodeByPath(slug: string[]) {
  const path = '/' + slug.join('/');
  const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
    cache: 'force-cache'
  });
  if (!res.ok) return null;
  return res.json() as Promise<{ title: string; html: string } | null>;
}

export default async function DocsPage({ params }: PageProps) {
  const segments = params.slug ?? []; // At the root, it's undefined, so convert to []
  const node = await getNodeByPath(segments);
  if (!node) notFound();

  return (
    <article>
      <h1>{node.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: node.html }} />
    </article>
  );
}

Key points:

  • At the root, params.slug will be undefined. Convert it to [] right away.
  • The route matches both /docs and any nested paths.

Breadcrumbs from [[...slug]]

import Link from 'next/link';

type CrumbsProps = { segments: string[] };

export function Breadcrumbs({ segments }: CrumbsProps) {
  const items = [
    { label: 'Docs', href: '/docs' },
    ...segments.map((s, i) => ({
      label: decodeURIComponent(s),
      href: '/docs/' + segments.slice(0, i + 1).map(encodeURIComponent).join('/')
    }))
  ];

  return (
    <nav aria-label="breadcrumb">
      <ol>
        {items.map(item => (
          <li key={item.href}>
            <Link href={item.href}>{item.label}</Link>
          </li>
        ))}
      </ol>
    </nav>
  );
}

Usage:

export default function DocsPage({ params }: { params: { slug?: string[] } }) {
  const segments = params.slug ?? [];
  return (
    <>
      <Breadcrumbs segments={segments} />
      {/* content */}
    </>
  );
}

Best practices:

  • Always use encodeURIComponent when generating links.
  • For display, you can decode to show human-readable names.

Generating Static Paths for [[...slug]]

If you know some paths ahead of time and want to prebuild them, use generateStaticParams. With an optional catch-all, you can generate both nested paths and the root.

export const revalidate = 300;

export async function generateStaticParams() {
  return [
    { slug: [] },                    // corresponds to /docs
    { slug: ['getting-started'] },   // corresponds to /docs/getting-started
    { slug: ['guides', 'install'] }  // corresponds to /docs/guides/install
  ];
}

Notes:

  • For [[...slug]], it’s convenient to describe the root path as { slug: [] }. This makes it clear that a no-segment route should be generated.
  • To strictly limit valid static paths, use:
export const dynamicParams = false; // any path not in generateStaticParams will return 404

SEO and Canonical Links for [[...slug]]

generateMetadata receives params and lets you generate metadata based on the segments.

import type { Metadata } from 'next';

type Props = { params: { slug?: string[] } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const segments = params.slug ?? [];
  const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');

  const res = await fetch(`${process.env.API_URL}/docs/meta?path=${encodeURIComponent(path)}`, {
    cache: 'force-cache'
  });
  if (!res.ok) return { title: 'Documentation' };

  const meta = await res.json() as { title: string; description?: string; canonical?: string };

  return {
    title: meta.title,
    description: meta.description,
    alternates: { canonical: meta.canonical ?? path },
    openGraph: { title: meta.title, description: meta.description }
  };
}

Recommendations:

  • Watch for duplicate URLs. If you use trailingSlash, make sure the canonical matches your actual configuration.
  • For data-driven pages, set a reasonable revalidate so metadata stays fresh.

Cache and Rendering Control

  • Static by default with ISR:export const revalidate = 300;
  • Fully dynamic response:
await fetch(url, { cache: 'no-store' });

// or at the route level: export const dynamic = 'force-dynamic';

  • Mixed approach: cache some data, fetch other data fresh. For frequently updated blocks, consider Route Handlers with their own cache.

[[...slug]] vs [...slug] and Common Pitfalls

  • Root handling [[...slug]] matches both the root and nested paths. At the root, params.slug is undefined. Normalize it to []. [...slug] never matches the root. At least one segment is required.
  • Typing Always type explicitly: params: { slug?: string[] }. This prevents errors when accessing params.slug.length.
  • Static paths Add { slug: [] } if you want to prebuild the root. With dynamicParams = false, any path not returned by generateStaticParams will give a 404.
  • Links Never concatenate segments directly. Use encodeURIComponent when generating hrefs.
  • Route conflicts Route groups (e.g. (marketing)) don’t appear in the URL but affect layout hierarchy. Keep [[...slug]] isolated in its section if you have similar patterns.
  • 404s Always call notFound() for invalid paths. Customize app/not-found.tsx for your UX.

Complete Example of a Documentation Section with [[...slug]]

app/
  docs/
    layout.tsx
    [[...slug]]/
      page.tsx
      loading.tsx
      not-found.tsx

layout.tsx:

export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="docs">
      <aside>{/* section menu */}</aside>
      <main>{children}</main>
    </div>
  );
}

page.tsx:

import { notFound } from 'next/navigation';
import { Breadcrumbs } from './_components/Breadcrumbs';

type PageProps = { params: { slug?: string[] } };

async function getDoc(path: string) {
  const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
    next: { revalidate: 300 }
  });
  if (!res.ok) return null;
  return res.json() as Promise<{ title: string; html: string } | null>;
}

export default async function DocsPage({ params }: PageProps) {
  const segments = params.slug ?? [];
  const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');
  const doc = await getDoc(path);
  if (!doc) notFound();

  return (
    <article>
      <Breadcrumbs segments={segments} />
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.html }} />
    </article>
  );
}

generateStaticParams.ts:

export const revalidate = 300;
export const dynamicParams = true; // allow not-listed paths

export async function generateStaticParams() {
  return [
    { slug: [] },                    // /docs
    { slug: ['getting-started'] },   // /docs/getting-started
    { slug: ['guides', 'install'] }  // /docs/guides/install
  ];
}

not-found.tsx:

export default function NotFound() {
  return <div>Page not found</div>;
}

Pre-Release Checklist

  • /docs opens the root page.
  • Deep paths like /docs/a/b/c render correctly.
  • params.slug is normalized to [] at the root.
  • Breadcrumbs generate correct, encoded links.
  • generateMetadata returns proper title and canonical for both root and nested pages.
  • notFound() works for invalid nodes.
  • revalidate and dynamic settings match freshness requirements.

Key Takeaways

[[...slug]] provides a clean architecture for sections where both the root and nested pages share a single page and layout. It simplifies navigation, breadcrumbs, SEO, and overall maintenance.

The most important points:

  • Type params.slug properly and normalize it to [] at the root.
  • Build links safely with encodeURIComponent.
  • Prebuild paths with { slug: [] } if needed.
  • Handle SEO with generateMetadata.
  • Always return notFound() for invalid paths.