Lesson 85·M10 App Router
Dynamic Routes
OpenLearning Objectives
By the end of this lesson you should be able to:
- Explain what a
[slug]folder does and the URL shape it captures - Read
params(aPromise) in a Server Component and render with it - Use
generateStaticParamsto pre-render a known set of routes - Contrast static rendering, dynamic rendering and
dynamicParamsbehavior - Describe catch-all and optional catch-all segments, and when to use each
- Say why a dynamic page's params decide its caching and revalidation story
One-Line Definition
A dynamic route is a segment written as [name] — one folder and one file that serve any value in that URL position, delivered to the page as params.
Mental Model
Think of a [slug] folder as a wildcard slot: the folder name defines a variable, the
URL supplies its value, and the page receives a bag of those values. The slot holds one
URL segment — /blog/[slug] captures exactly one word. A catch-all [...slug] is a slot
that grabs everything after it, like /*.
app/blog/[slug]/page.tsx
/blog/app-router → params.slug = 'app-router'
/blog/layouts → params.slug = 'layouts'
/blog/hello-world → params.slug = 'hello-world'
one file, three URLs, three different paramsVisual Flow
request /blog/hello-world
│
▼
app/blog/
[slug]/
page.tsx → params = { slug: 'hello-world' }
not-found.tsx → rendered when no post matches
layout.tsx → wraps every post
app/layout.tsx → wraps everything
│
▼
server: generateStaticParams() lists known slugs → pre-rendered statically
unknown slug (dynamicParams) → rendered on demand (or 404)
Static params get pre-rendered at build time; everything else is decided per request — that single fork is the whole caching story of dynamic routes.
How It Works
A folder with brackets captures its URL segment into a params object:
app/
blog/
[slug]/
page.tsx → /blog/:slug params: { slug }
posts/
[id]/
page.tsx → /posts/:id params: { id }
[lang]/
docs/[...slug]/ → /:lang/docs/* params: { lang, slug: string[] }
The segment value is delivered as params — in a Server Component it's a Promise, so
you await it (Lesson 86's async components make this natural):
// app/blog/[slug]/page.tsx — Server Component
import { getPost } from '@/lib/db';
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // params is a Promise in the App Router
const post = await getPost(slug);
if (!post) return <NotFound />; // no match → not-found.tsx renders
return <article><h1>{post.title}</h1></article>;
}
GET /blog/hello-world
→ params resolves to { slug: 'hello-world' }
→ getPost('hello-world') runs on the server
→ no match renders the not-found view, still wrapped in layouts
Note
In pages/ (old router) params was a plain synchronous object available to
getServerSideProps. In the App Router it's a Promise<Record<string, string | string[]>>
— await params is the new norm.
Real Project Usage
| Pattern | Folder | params you get |
|---|---|---|
| Blog posts | app/blog/[slug]/page.tsx | { slug: string } |
| Product pages | app/products/[id]/page.tsx | { id: string } |
| Localized docs | app/[lang]/docs/[...slug]/page.tsx | { lang: string, slug: string[] } |
| Catch-all dashboards | app/dashboard/[...segments]/page.tsx | { segments: string[] } |
Pre-rendering a known set — the canonical generateStaticParams usage:
// app/blog/[slug]/page.tsx
import { getAllPosts } from '@/lib/db';
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
build time: for each slug returned, Next.js pre-renders /blog/<slug> as static HTML
visits to those URLs need no server work; unknown slugs fall back to
on-demand rendering (or 404) depending on dynamicParams
The static list is the optimization and the 404 rule in one: known URLs are pre-built, unknown ones are the app's decision.
Interview Explanation
A dynamic route is a folder written
[name]—app/blog/[slug]/page.tsx— that captures one URL segment into aparamsobject, so a single file serves/blog/app-router,/blog/layouts, and every other slug. In a Server Component,paramsis a Promise that you await before reading the value.generateStaticParamsreturns the list of known values, and those routes are pre-rendered at build time; slugs outside the list render on demand whendynamicParamsis true, or 404 when it isn't. A catch-all[...name]captures the rest of the path as an array, and[[...name]]makes even that optional.
That's the 30-second answer: bracket folder, awaited params, static list at build, on demand for the rest.
Senior-Level Insights
- "One file, many URLs" is the route-count problem. A dynamic route collapses N URLs
into one file, which means metrics, logging and access rules all key off
params, not the path. Senior engineers design around that: the slug is data, so validation and normalization happen in the page or a shared helper, once. paramsis the caching key. Whether a dynamic page renders statically, dynamically, or with revalidation (Lessons 90 and 91) is decided per segment value. The same file can be static for known slugs and dynamic for unknown ones — params is the fork in the road.- Static ≠ always static.
generateStaticParamsis a build-time list, but with ISR/revalidation that list can grow and refresh at runtime. Saying "static params are frozen at build" is the answer a candidate who read the docs once gives. - Catch-alls are a route architecture decision.
[...slug]hands you an array and punts the routing to your code. Use it for genuinely tree-shaped paths (docs, dashboards); for one level of variation a[slug]is simpler and keeps the 404 semantics for free.
Common Mistakes
- Reading
paramswithout awaiting. In a Server Componentparamsis aPromise.params.slugdirectly throws/never resolves;const { slug } = await paramsis the fix. - Expecting numeric params. Segments are strings —
/products/42givesid: "42". Parse before use:Number(id), or validate before it reaches the database. - Forgetting
dynamicParams. With itfalse, a URL outsidegenerateStaticParamsreturns 404. With ittrue(default), unknown slugs render on demand. Which one you want changes the app's failure behavior — pick deliberately. - Using a catch-all where a
[slug]fits.[...slug]makes/blog/a/b/call valid and pushes parsing into your code; a single[slug]keeps one level and native 404s. - Duplicating segment names in one path.
app/[slug]/[slug]/reuses one key — the second overrides the first. Rename one of the folders. - Returning the wrong shape from
generateStaticParams. The keys must match the segment names exactly —{ slug }for[slug],{ lang }for[lang]— and catch-alls expect arrays, not strings.
Best Practices
✅ Await params in every Server Component that reads it
✅ Treat segment values as strings — parse, validate, normalize before use
✅ Return generateStaticParams with keys matching segment names, arrays for catch-alls
✅ Set dynamicParams explicitly so unknown-slug behavior is a decision, not a default
✅ Keep one level of variation in [slug]; reach for [...slug] only for trees
❌ Don't read params.slug directly in an async Server Component
❌ Don't let unvalidated params reach a database query — that's the injection surface
Interview Questions
Q1. What is a dynamic route in the App Router?
A folder written with brackets, like
app/blog/[slug]/page.tsx, that captures one URL segment into aparamsobject. The same file serves every value of that segment —/blog/anything— and the page reads which value it got fromparams.
Q2. How do you access params in a Server Component?
paramsis passed as a prop and is aPromisein a Server Component, so you await it:const { slug } = await params. This lets the framework defer segment resolution while the component streams. In the oldpages/router it was a plain synchronous object.
Q3. What does generateStaticParams do?
It returns the list of known segment values. At build time Next.js pre-renders each one as static HTML —
/blog/hello-worldfor every returned slug. The page then serves those URLs with zero server work per request, and the app decides what happens for values not in the list.
Q4. What happens for a slug not in generateStaticParams?
It depends on
dynamicParams. With the defaulttrue, the route renders on demand when requested. Withfalse, an unknown slug returns 404. That switch is how you choose between "render anything" and "only what I enumerated".
Q5. What is a catch-all segment?
[...slug]captures the rest of the path as an array —/docs/a/b/cgives{ slug: ['a','b','c'] }. The optional form[[...slug]]also matches when nothing is there. They're for tree-shaped routes like docs and dashboards where a fixed segment can't express the depth.
Senior follow-up: A product page must always show fresh prices, but you also want it to load fast. How do you reconcile those with a dynamic route?
The route is one file; the segment value decides the strategy.
generateStaticParamspre-renders the product list, but the pricing data inside is fetched with revalidation (Lesson 90) — or the page is marked dynamic and the expensive fetch is cached — so known URLs still get a static shell while prices stay current. The insight is that params isn't just data for rendering; it's the key that selects each URL's caching and revalidation policy, and I'd make that per-segment decision explicit instead of one global setting.
Follow-up Questions
Is params a string or a number?
Always a string — URL segments are text.
/products/42yields"42", so parse withNumber(id)and validate before the value touches a database query or an ID comparison.
Can dynamic segments be nested?
Yes —
app/[lang]/docs/[...slug]/is valid, andparamsmerges them:{ lang, slug }. Just don't reuse a name twice in one path, or the later segment overrides the earlier one.
Do dynamic routes re-render on every request?
Only when they're dynamically rendered. Statically generated params (and ISR-revalidated ones, Lesson 91) are served from the cache; the "render every request" path is what you get with
dynamicParamson-demand rendering orforce-dynamic. Params selects the mode, not the file.
Comparison Table
[slug] | [...slug] | [[...slug]] | |
|---|---|---|---|
| Matches | one segment | one or more | zero or more |
params value | string | string[] | string[] or [] |
/blog | ❌ | ❌ | ✅ |
/blog/a | ✅ | ✅ | ✅ |
/blog/a/b | ❌ | ✅ | ✅ |
| Use when | one level of variation | tree-shaped paths | optional catch-all (e.g. root landing) |
Code Example
A complete dynamic route with static params and a catch-all, runnable in shape:
// app/blog/[slug]/page.tsx
const posts = ['app-router', 'layouts', 'dynamic-routes'];
export function generateStaticParams() {
return posts.map((slug) => ({ slug }));
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <article><h1>Post: {slug}</h1></article>;
}
build time: /blog/app-router, /blog/layouts, /blog/dynamic-routes pre-rendered
GET /blog/app-router → <h1>Post: app-router</h1> (served statically)
GET /blog/unknown → rendered on demand (dynamicParams) or 404
// app/docs/[...slug]/page.tsx — catch-all
export default async function DocsPage({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
return <p>Docs: {slug.join(' / ')}</p>;
}
GET /docs/nextjs/app-router → <p>Docs: nextjs / app-router</p>
GET /docs/nextjs → <p>Docs: nextjs</p>
The same file serves any depth — the array is what you join or walk.
Performance Notes
generateStaticParamsis the fastest mode. Known slugs ship as static files — zero server work, CDN-cacheable, instant on repeat visits. The list size is the only cost.- On-demand rendering moves work per request. Unknown slugs render when asked; that per-request cost is real and is exactly what Lesson 90's caching exists to absorb.
- The list is a memory/scale knob. A huge product catalog wants pagination or revalidation (Lesson 91), not an unbounded static list. Think in which URLs must be static, not how many.
- Catch-alls are harder to cache well. The deeper the path, the more combinations — a catch-all with fine-grained revalidation can generate more work than it saves.
- Don't over-tune. A small blog pre-renders everything and needs nothing else. The per-segment caching matrix is only worth it when you actually have hot and cold URLs.
Debugging Scenarios
Scenario 1: "My dynamic page renders [object Promise]."
You read params without awaiting. params is a Promise in a Server Component —
params.slug is undefined, and String(params) yields [object Promise]. Fix:
const { slug } = await params.
Scenario 2: "Unknown slugs render a blank page instead of 404."
The page renders without checking the fetched record, and dynamicParams allows on-demand
rendering. Return a notFound() (or render the not-found view) when the record is missing,
and decide whether dynamicParams: false better matches the app.
Scenario 3: "A catch-all route shadows a fixed route."
app/blog/[...slug] can match paths a fixed app/blog/featured also claims. Static
segments win over dynamic ones — if the wrong handler serves, reorder or rename so the
static route is found first.
Scenario 4: "generateStaticParams returns routes that 404 at runtime."
The returned keys don't match the segment names, or the values contain characters that aren't valid in a URL. Keys must equal the bracket names exactly, and values should be slug-safe (encode or normalize before returning).
Quick Revision Notes
[slug]folder = one captured segment;[...slug]= rest of path asstring[]paramsis aPromisein Server Components — alwaysawaitit- Segment values are strings; parse/validate before they reach a DB query
generateStaticParamsreturns known values → pre-rendered at build timedynamicParams: true(default) renders unknown slugs on demand;false→ 404- Catch-all
[[...slug]]also matches zero segments - Static segments win over dynamic ones when paths overlap
paramsis the key that picks each URL's caching mode (Lessons 90–91)
Cheat Sheet
app/blog/[slug]/page.tsx → /blog/:slug params: { slug: string }
app/[lang]/docs/[...slug]/ → /:lang/docs/* params: { lang, slug: string[] }
app/[[...slug]]/page.tsx → /* or / params: { slug: string[] } (may be [])
rules:
folder name in [] = the param key
segment value = always a string
await params = required in Server Components
generateStaticParams() returns [{ key: value }, …] → static at build
dynamicParams: false → unknown values 404
[...name] = array [[...name]] = optional
comparison:
[slug] one level /blog/a params.slug = 'a'
[...slug] one or more /blog/a/b params.slug = ['a','b']
[[...slug]] zero or more /blog params.slug = []Key Takeaways
Recap
- A dynamic route is a bracketed folder that captures one URL segment into
params - In a Server Component
paramsis aPromise—awaitit before reading - Segment values are strings; validate and parse before they reach a query
generateStaticParamspre-renders the known set;dynamicParamsdecides the rest- Catch-alls (
[...slug]) and optional catch-alls ([[...slug]]) serve tree-shaped paths - Static segments win over dynamic ones on overlapping paths
paramsisn't just render data — it's the key that selects each URL's caching policy- This is the vocabulary every later lesson (86, 89, 90, 91) builds on
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/7What does the folder app/products/[id] capture, and what does params look like for /products/42?
Why must you await params in a Server Component?
What does generateStaticParams return, and what does Next.js do with it at build time?
What is the difference between dynamicParams: true and dynamicParams: false?
When would you choose [...slug] over [slug]?
What does params contain for /docs/guide/setup served by app/docs/[...slug]/page.tsx?
A dynamic page shows stale prices. Which knob — and which lesson — fixes it?
What's Next
Lesson 86 — Server Components. You know which files map to routes and what params
means; now learn what actually runs where — async components, the RSC payload, and why a
page with zero client JS isn't a bug.