![How to set up dynamic routes in Next.js. Full breakdown of [[...slug]]](/images/uploads/next-routing-slug.webp)
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 beundefined
. 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
isundefined
. 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 accessingparams.slug.length
. - Static paths
Add
{ slug: [] }
if you want to prebuild the root. WithdynamicParams = false
, any path not returned bygenerateStaticParams
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. Customizeapp/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
anddynamic
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.