Dynamic Routing
You want to show blog posts at /my-first-post, /nextjs-tips, and /anything-else. Creating a separate file for each post is absurd. You need one page that handles any slug.
Dynamic routes solve this. You create [slug]/page.tsx and Next.js matches any path segment, passing it to your component as a parameter. This is how every content-driven site works: blogs, docs, product pages, user profiles.
The starter includes a separate apps/blog application running on port 3001 with dynamic routing already working. In this lesson, you'll explore how the existing route works, then enhance it with static generation.
Outcome
Explore the existing blog dynamic route at apps/blog/src/app/[slug]/page.tsx, understand async params and notFound(), then add generateStaticParams to pre-render posts at build time.
Fast Track
- Explore the existing
apps/blog/src/app/[slug]/page.tsxto understand async params andnotFound() - Add
generateStaticParamsto the existing file to pre-render posts at build time - Test at
http://localhost:3001/your-post-slugto verify dynamic routing works
In Next.js 16, params and searchParams are Promises. You must await them before accessing values:
export default async function Page(props: {
params: Promise<{ slug: string }>
}) {
const params = await props.params
// Now you can use params.slug
}This is a breaking change from earlier versions where params were synchronous.
Building on Server and Client Components
In Server and Client Components, you learned that Server Components can access server-only resources like environment variables and databases. Dynamic route pages are Server Components by default, which means you can:
- Fetch data directly without an API endpoint
- Access the
@repo/apipackage to get blog posts - Keep your data fetching logic on the server
We're switching to apps/blog (port 3001) for this lesson to explore dynamic routing in a content context. The blog app demonstrates real-world patterns like [slug] routes for posts, where each piece of content has its own URL path.
As you learned in Project Setup, the starter includes multiple apps. The blog is a separate Next.js application optimized for content delivery, while apps/web is your main marketing site. This separation lets you configure caching, rendering, and deployment strategies differently for each use case.
Step 1: Explore the Existing Dynamic Route
The starter already has a working blog with dynamic routing at apps/blog/src/app/[slug]/page.tsx. Let's understand how it works.
Open the file and examine the code:
import { fetchPostBySlug } from '@repo/api/blog';
import Link from 'next/link';
import { notFound } from 'next/navigation';
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPostBySlug(slug);
if (!post) {
notFound();
}
return (
<main className="flex flex-col gap-6">
<Link href="/" className="text-sm text-blue-600 hover:underline">
← Back to posts
</Link>
<article className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<h1 className="font-bold text-4xl">{post.title}</h1>
<p className="text-sm text-gray-500">
{post.category} · {post.readingTime} min read
</p>
<p className="text-sm text-gray-500">By {post.author.name}</p>
</header>
<div className="prose max-w-none">
{post.content.split('\n\n').map((paragraph, i) => (
<p key={i} className="mb-4">
{paragraph}
</p>
))}
</div>
</article>
</main>
);
}Key points:
- The folder name
[slug]tells Next.js this is a dynamic segment paramsis typed asPromise<{ slug: string }>(Next.js 16 requirement)- You must
await paramsbefore accessingslug fetchPostBySlugcomes from the shared@repo/apipackagenotFound()triggers the 404 page when the post doesn't exist
Try it now:
Start the dev server from the monorepo root if not already running:
# From the monorepo root (nextjs-foundations/)
pnpm devThe blog app runs on port 3001. Visit http://localhost:3001 to see the blog index, then click any post to see the dynamic route in action. Notice the URL changes to match the post slug.
Dynamic Routes and Prerendering
Dynamic routes like [slug] are dynamic (ƒ) by default, but they can prerender per segment value with generateStaticParams(). Each unique slug gets its own prerendered page:
Route (app) Size First Load JS
├ ○ /[slug] 1.2 kB 85.2 kB
│ ├ /hello-world (prerendered)
│ ├ /nextjs-routing (prerendered)
│ └ /react-tips (prerendered)
How Next.js knows which slugs to prerender:
- At build time: Next.js discovers slugs from
generateStaticParams() - On-demand: New slugs are rendered and cached on first request (when
dynamicParams = true)
This is the foundation for caching per-resource data. In Lesson 3.1: Cache Components, you'll learn to add cacheTag('post-' + slug) for fine-grained cache invalidation.
Step 2: Add Static Generation
Now enhance the existing dynamic route with static generation. For better performance, pre-render popular posts at build time by adding generateStaticParams.
Open apps/blog/src/app/[slug]/page.tsx and add this function at the top of the file:
import { fetchPostBySlug, fetchPosts } from '@repo/api/blog';
import Link from 'next/link';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await fetchPosts(10); // Pre-render top 10 posts
return posts.map((post) => ({
slug: post.slug,
}));
}
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPostBySlug(slug);
if (!post) {
notFound();
}
// ... rest of component unchanged
}How generateStaticParams works:
- Runs at build time to determine which paths to pre-render
- Returns an array of param objects:
[{ slug: 'post-1' }, { slug: 'post-2' }] - Each object becomes a static HTML page (○ in build output)
- Paths not returned are rendered on-demand (dynamic) by default
By default, dynamicParams = true, meaning slugs not in generateStaticParams are rendered on-demand. Set export const dynamicParams = false to return 404 for unknown slugs instead.
With generateStaticParams(), dynamic routes become static at build time. Each generated page is cached and served instantly. For cache invalidation after updates (like when a post is edited), see cacheTag() in Lesson 3.1: Cache Components.
In development mode, generateStaticParams runs on-demand, not at startup. To see it in action:
# From the monorepo root
pnpm buildThe build output shows which pages were pre-rendered as static HTML.
Step 3: Explore the Blog Index
The starter also includes a blog index page that lists all posts. Open apps/blog/src/app/page.tsx to see how it works:
import { fetchPosts } from '@repo/api/blog';
import Link from 'next/link';
export default async function BlogHomePage() {
const posts = await fetchPosts(10);
return (
<main className="flex flex-col gap-8">
<h1 className="font-bold text-4xl">Blog</h1>
<div className="flex flex-col gap-6">
{posts.map((post) => (
<article key={post.id} className="flex flex-col gap-2 border-b pb-6">
<Link href={`/${post.slug}`} className="hover:underline">
<h2 className="font-semibold text-2xl">{post.title}</h2>
</Link>
<p className="text-sm text-gray-500">
{post.category} · {post.readingTime} min read
</p>
<p className="text-gray-700">{post.excerpt}</p>
<Link
href={`/${post.slug}`}
className="text-sm text-blue-600 hover:underline"
>
Read more →
</Link>
</article>
))}
</div>
</main>
);
}Key points:
fetchPosts(10)gets the 10 most recent posts- Links use relative paths like
/${post.slug}(no/blogprefix needed since this app is dedicated to the blog) - Clicking a link navigates to the dynamic route you explored in Step 1
Try It
-
Visit the blog index: Navigate to
http://localhost:3001. You should see a list of posts. -
Click a post: Click any post title. The URL changes to
http://localhost:3001/[actual-slug]and shows the full post. -
Test the notFound pattern: The demo API generates content for any slug (useful for development). In production with a real database,
fetchPostBySlugwould returnnullfor missing posts, triggeringnotFound(). To see this in action, temporarily modifyapps/blog/src/app/[slug]/page.tsxto force a 404:if (!post || slug === 'test-not-found') { notFound(); }Then visit
http://localhost:3001/test-not-foundto see the 404 page. -
Verify params are working: Add this temporarily to your page to see the raw params:
<pre>{JSON.stringify({ slug }, null, 2)}</pre>
Expected output when visiting http://localhost:3001/some-post-slug:
{
"slug": "some-post-slug"
}Dynamic Route Patterns
Next.js supports three dynamic segment patterns:
┌─────────────────────────────────────────────────────────────────────────┐
│ Pattern │ Example URL │ params Shape │
├─────────────────────────────────────────────────────────────────────────┤
│ [slug] │ /blog/hello │ { slug: "hello" } │
│ [...slug] │ /docs/a/b/c │ { slug: ["a","b","c"] } │
│ [[...slug]] │ /shop or /shop/a/b │ { slug: [] } or ["a","b"] │
└─────────────────────────────────────────────────────────────────────────┘
[slug]- Single segment. Matches exactly one path part.[...slug]- Catch-all. Requires at least one segment./docsalone returns 404.[[...slug]]- Optional catch-all. Matches zero or more segments./shopworks.
For this blog, [slug] is the right choice: each post has exactly one slug.
Use [...slug] for nested documentation (/docs/api/auth/oauth) or category paths (/products/electronics/phones). The slug array lets you handle arbitrary depth.
Catch-All Routes in Practice
The following examples use apps/web (port 3000) to give you hands-on practice creating routes from scratch. The blog app already has its [slug] route configured, so we'll explore catch-all patterns in the web app where you can experiment freely.
This demonstrates another advantage of the monorepo architecture from Lesson 1.1: you can prototype and test routing patterns in one app without affecting the other.
The table shows three patterns, but the catch-all variants ([...slug] and [[...slug]]) have subtle differences that matter. Let's see them in action.
Required Catch-All: [...slug]
A required catch-all matches one or more segments, but does not match the base route.
Create a documentation site structure:
export default async function DocsPage(props: {
params: Promise<{ slug: string[] }>
}) {
const params = await props.params
// params.slug is an array of path segments
const path = params.slug.join('/')
return (
<div className="p-8">
<h1 className="mb-4 text-2xl font-bold">Documentation</h1>
<p className="mb-2">Full path: /docs/{path}</p>
<p className="mb-4">Segments: {params.slug.length}</p>
<ul className="list-disc pl-6">
{params.slug.map((segment, i) => (
<li key={i}>
Segment {i}: {segment}
</li>
))}
</ul>
</div>
)
}Key points:
params.slugis typed asstring[], notstring- URL
/docs/api/authproducesslug: ["api", "auth"] - URL
/docs/getting-startedproducesslug: ["getting-started"] - URL
/docsreturns 404 (no segments to catch)
This pattern is correct when the base route (/docs) should either:
- Not exist at all, OR
- Be handled by a separate
apps/web/src/app/docs/page.tsxfile
Optional Catch-All: [[...slug]]
An optional catch-all matches zero or more segments, including the base route.
Create a shop with optional category filtering:
export default async function ShopPage(props: {
params: Promise<{ slug?: string[] }>
}) {
const params = await props.params
// params.slug is undefined when no segments present
const categories = params.slug || []
const isRoot = categories.length === 0
return (
<div className="p-8">
<h1 className="mb-4 text-2xl font-bold">
{isRoot ? 'All Products' : 'Filtered Products'}
</h1>
{isRoot ? (
<p>Showing all categories</p>
) : (
<div>
<p className="mb-2">Category path: /{categories.join('/')}</p>
<p className="mb-4">Depth: {categories.length}</p>
<ul className="list-disc pl-6">
{categories.map((category, i) => (
<li key={i}>
Level {i + 1}: {category}
</li>
))}
</ul>
</div>
)}
</div>
)
}Key points:
params.slugis typed asstring[] | undefined- URL
/shopproducesslug: undefined(base route matches) - URL
/shop/electronicsproducesslug: ["electronics"] - URL
/shop/electronics/phonesproducesslug: ["electronics", "phones"] - You must handle the undefined case when no segments are present
This pattern is correct when the base route should show content (like "all products" or a category root).
Common Mistakes with Catch-All Routes
Mistake 1: Treating slug as a string
// Wrong - slug is an array, not a string
const params = await props.params
const title = params.slug.toUpperCase() // Type error!
// Correct - join the array or access elements
const path = params.slug.join('/')
const firstSegment = params.slug[0]Mistake 2: Forgetting params is async
// Wrong - accessing params before awaiting
export default async function Page(props: {
params: Promise<{ slug: string[] }>
}) {
const slug = props.params.slug // Runtime error!
// Correct - await first
const params = await props.params
const slug = params.slug
}Mistake 3: Using required catch-all when base route should match
// File: app/products/[...slug]/page.tsx
// Problem: /products returns 404, but you want it to show "all products"
// Solution: Use optional catch-all instead
// File: app/products/[[...slug]]/page.tsx
// Now /products works AND /products/electronics worksTry It: Catch-All Routes
-
Create the required catch-all route:
mkdir -p "apps/web/src/app/docs/[...slug]"Add the DocsPage component from above to
apps/web/src/app/docs/[...slug]/page.tsx. -
Test the required catch-all:
- Visit
http://localhost:3000/docs/api- should render withslug: ["api"] - Visit
http://localhost:3000/docs/api/auth/oauth- should render withslug: ["api", "auth", "oauth"] - Visit
http://localhost:3000/docs- should show 404 (required catch-all needs at least one segment)
- Visit
-
Create the optional catch-all route:
mkdir -p "apps/web/src/app/shop/[[...slug]]"Add the ShopPage component from above to
apps/web/src/app/shop/[[...slug]]/page.tsx. -
Test the optional catch-all:
- Visit
http://localhost:3000/shop- should render with "All Products" (slug is undefined) - Visit
http://localhost:3000/shop/electronics- should render withslug: ["electronics"] - Visit
http://localhost:3000/shop/electronics/phones/iphone- should render with all three segments
- Visit
-
Verify the type difference: Add this to inspect the raw params in your browser:
<pre className="mt-4 rounded bg-gray-100 p-4"> {JSON.stringify({ slug: params.slug }, null, 2)} </pre>
Expected behavior:
| URL Pattern | Required [...slug] | Optional [[...slug]] |
|---|---|---|
/route | 404 | { slug: undefined } |
/route/a | { slug: ["a"] } | { slug: ["a"] } |
/route/a/b/c | { slug: ["a","b","c"] } | { slug: ["a","b","c"] } |
Questions 11 and 12 in the quiz test your understanding of when to use [...slug] vs [[...slug]]. The key difference is whether the base route (like /shop) should match or return 404.
Commit
git add -A
git commit -m "feat: add generateStaticParams to blog dynamic route"
git pushDone-When
- You understand how
apps/blog/src/app/[slug]/page.tsxuses async params generateStaticParamsis added toapps/blog/src/app/[slug]/page.tsx- You understand that dynamic routes prerender per segment with
generateStaticParams() - Visiting
http://localhost:3001shows the blog index - Clicking a post navigates to
http://localhost:3001/[slug]and renders the full post notFound()is called when post is null (verified by testing with a non-existent slug)
Troubleshooting
params.slug is undefined
You're accessing params before awaiting:
// Wrong - params is a Promise
const slug = props.params.slug
// Correct - await first
const params = await props.params
const slug = params.slugIn Next.js 16, params is always a Promise in Server Components.
Module not found: @repo/api/blog
The @repo/api package should already be configured. Check that:
packages/api/src/blog.tsexistspackages/api/package.jsonhas the export configured- Run
pnpm installto ensure dependencies are linked
If issues persist, check apps/blog/package.json includes @repo/api in dependencies.
notFound is not a function
Import it from the correct location:
import { notFound } from 'next/navigation'Not from next/router (that's Pages Router).
generateStaticParams not running
In dev mode, generateStaticParams runs on-demand, not at startup. To verify it works, run from the monorepo root:
pnpm buildYou'll see the static pages listed in the build output.
Ask your coding agent for help. Paste the error message and it can diagnose the issue.
I'm getting undefined params in my Next.js 16 dynamic route.
**Error or unexpected behavior:**
```
___DESCRIBE_WHAT_HAPPENS___
```
Example: "params.slug is undefined" or "Cannot read property 'id' of undefined"
**My page component:**
```tsx
// File path: _____
// Example: src/app/blog/[slug]/page.tsx
___PASTE_YOUR_PAGE_COMPONENT___
```
**The URL I'm visiting:** /_____
**Expected param value:** _____
**Note:** Next.js 16 changed params to be async. The old pattern was:
```tsx
// OLD (Next.js 14)
export default function Page({ params }) {
return <div>{params.slug}</div>
}
// NEW (Next.js 16)
export default async function Page({ params }) {
const { slug } = await params
return <div>{slug}</div>
}
```
Am I using the correct async params pattern? What's wrong with my implementation?Solution
Modified blog post page with generateStaticParams
Add generateStaticParams to Existing File
The only change needed is adding the generateStaticParams function and importing fetchPosts:
import { fetchPostBySlug, fetchPosts } from '@repo/api/blog';
import Link from 'next/link';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await fetchPosts(10);
return posts.map((post) => ({
slug: post.slug,
}));
}
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPostBySlug(slug);
if (!post) {
notFound();
}
return (
<main className="flex flex-col gap-6">
<Link href="/" className="text-sm text-blue-600 hover:underline">
← Back to posts
</Link>
<article className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<h1 className="font-bold text-4xl">{post.title}</h1>
<p className="text-sm text-gray-500">
{post.category} · {post.readingTime} min read ·{' '}
{post.publishedAt.toLocaleDateString()}
</p>
<p className="text-sm text-gray-500">By {post.author.name}</p>
</header>
<div className="prose max-w-none">
{post.content.split('\n\n').map((paragraph, i) => (
<p key={i} className="mb-4">
{paragraph}
</p>
))}
</div>
<footer className="flex flex-wrap gap-2 border-t pt-4">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded bg-gray-100 px-2 py-1 text-sm text-gray-600"
>
{tag}
</span>
))}
</footer>
</article>
</main>
);
}Key Implementation Notes
-
Async params pattern: The existing file already uses
await paramscorrectly. This is required in Next.js 16. -
generateStaticParams: Returns an array of param objects. Each becomes a pre-rendered page. The
slugproperty must match your folder name[slug]. -
notFound(): Already imported from
next/navigation. Triggers the nearestnot-found.tsxfile or the default 404 page. -
Data fetching:
fetchPostBySlugandfetchPostsare from@repo/api/blog. The API uses faker.js to generate realistic post data. -
Fallback behavior: By default (
dynamicParams = true), slugs not ingenerateStaticParamsare rendered on-demand. Addexport const dynamicParams = falseto 404 on unknown slugs. -
Blog index: The
apps/blog/src/app/page.tsxfile already exists with the blog listing. No changes needed there.
Learn More
- Dynamic Routes - File conventions for
[slug],[...slug],[[...slug]] - generateStaticParams - Pre-render dynamic routes at build time
- notFound - Trigger 404 responses
What's Next
You now have dynamic routes that fetch real data and handle missing resources gracefully. The next lesson covers environment variables and security, showing you how to safely manage secrets that your Server Components can access while keeping them hidden from the client bundle.
Was this helpful?