You’ve seen them. URLs like /blog/how-to-nextjs or /products/42 that load the right content without a separate file for every possible path. That’s dynamic routing — and it’s one of the most important patterns you’ll use in any Next.js project.
The App Router (introduced in Next.js 13 and now the default in Next.js 15 and 16) changed how dynamic routes work in meaningful ways. If you’ve been copy-pasting getStaticPaths code from older tutorials, this guide will get you up to date — including the params-as-Promise change that started in Next.js 15 and became fully required in Next.js 16.
What Dynamic Routes Actually Do
In Next.js, a dynamic route handles any URL that matches a pattern rather than a fixed path. Instead of creating blog/post-1.tsx, blog/post-2.tsx, and so on, you create a single file with a bracket-named segment, and Next.js routes all matching URLs through it.
The route receives the dynamic segment as a parameter, which your component uses to fetch and render the right content. One file, any number of pages.
Dynamic Routes in the App Router
In the App Router, dynamic segments live in folder names inside app/. A folder named [slug] catches any single segment at that level.
File structure:
app/
blog/
[slug]/
page.tsx
Component:
// 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 getPostBySlug(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
No useRouter. No getStaticProps. The component is a server component by default — it fetches data directly and renders on the server.
The Breaking Change: params Is Now a Promise
This is the one that catches people. In Next.js 13 and 14, params was a plain object you could destructure immediately:
// Next.js 14 — worked fine
export default async function Page({ params }: { params: { slug: string } }) {
const { slug } = params; // direct access, no await
}
Starting in Next.js 15, params is a Promise. Next.js 15 kept synchronous access working for backwards compatibility, but flagged it as deprecated. In Next.js 16, you must await it — sync access is no longer supported. The correct pattern:
// Next.js 15 and 16 — correct
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // always await
}
Same change applies to searchParams. If you’re upgrading from Next.js 14 — or from 15 to 16 — this is one of the first things to audit.
Replacing getStaticPaths: generateStaticParams
In the Pages Router, getStaticPaths told Next.js which dynamic paths to pre-render at build time. In the App Router, that role belongs to generateStaticParams.
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await fetchAllSlugs(); // returns ['hello-world', 'nextjs-guide']
return posts.map((slug) => ({ slug }));
}
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound(); // renders the nearest not-found.tsx
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
generateStaticParams runs at build time. Next.js pre-renders one page per returned object. For paths not in the list, behavior depends on your dynamicParams config — by default they’re rendered on demand.
Catch-All and Optional Catch-All Routes
Sometimes you need to match multiple segments at once — like /docs/getting-started/installation or /products/category/subcategory/item. That’s where catch-all routes come in.
Catch-all ([...slug]) — matches one or more segments:
app/
docs/
[...slug]/
page.tsx
// app/docs/[...slug]/page.tsx
type Props = {
params: Promise<{ slug: string[] }>;
};
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// /docs/a → slug = ['a']
// /docs/a/b/c → slug = ['a', 'b', 'c']
return <div>Path: {slug.join(' / ')}</div>;
}
Optional catch-all ([[...slug]]) — also matches the root path with no segments:
app/
docs/
[[...slug]]/
page.tsx
type Props = {
params: Promise<{ slug?: string[] }>;
};
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
// /docs → slug = undefined
// /docs/a/b → slug = ['a', 'b']
if (!slug) return <div>Docs home</div>;
return <div>Path: {slug.join(' / ')}</div>;
}
Use optional catch-all when the same layout needs to handle both a root page and nested paths.
Dynamic SEO with generateMetadata
Dynamic routes usually need dynamic <title> and <meta> tags too. The App Router handles this with generateMetadata, exported from the same page.tsx file:
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
Next.js calls generateMetadata at build time for static paths and on demand for dynamic ones. No extra packages, no helmet — it’s built in.
The Pages Router (For Reference)
If you’re maintaining a codebase still on the Pages Router, the pattern is different. Dynamic files live in pages/ with bracket names (pages/blog/[slug].js), and you use getStaticPaths + getStaticProps for static generation or getServerSideProps for server-side rendering.
The Pages Router isn’t going away, but all new Next.js development should target the App Router. If you’re starting a project in 2026, start with app/.
What’s Next?
Once your routes are in place, the next challenge is usually controlling who can access them — which is exactly what authentication and protected routes in the App Router covers.
Dynamic routes give you the structure. Auth gives you the lock on the door. Both are things senior devs still Google — and now you won’t have to.