Dynamic and catch-all segments
The Next.js App Router's bracketed folders, which let one page serve a whole family of URLs by capturing parts of the path as typed route parameters.
Every invoice in your app needs a detail page. There are forty of them today and a thousand by next quarter, so you are not going to write a page.tsx for each id. For two lessons the file system has been a static route table: one folder, one URL. This lesson shows how one folder can stand in for an unbounded set of URLs. By the end you can build /invoices/[id], nest it under a tenant so it reads /orgs/[orgSlug]/invoices/[id], and handle a docs-style URL whose depth you don’t know in advance. You’ll also pick up the habit that separates a working page from a shippable one: keeping the id from the address bar out of your database until you’ve checked it. One warning up front: the catch-all tools at the end look powerful, so people reach for them constantly, but they fit only a narrow set of cases. You’ll use a plain [id] ninety percent of the time.
A folder in brackets is a parameter
Section titled “A folder in brackets is a parameter”You already know that app/invoices/page.tsx produces the URL /invoices. Wrap the folder name in square brackets, as in app/invoices/[id]/page.tsx, and you’ve created a dynamic segment : a single folder that matches any value in that position of the URL. /invoices/inv_42, /invoices/inv_99, and /invoices/anything all resolve to that one page.tsx.
Directorysrc/
Directoryapp/
Directoryinvoices/
Directory
[id]/the bracketed folder, matches any value- page.tsx
The framework captures whatever filled that slot and hands it to your page as a route parameter . The rule for the name is mechanical: the text inside the brackets becomes the property name. [id] gives you params.id, and renaming the folder to [invoiceId] means you read params.invoiceId. The captured value is always a string, because a URL holds no numbers, only characters a user can type.
That last point is worth making concrete before any code. Here is what lands in params for a few different URLs hitting app/invoices/[id]/page.tsx:
[id] [id] [id] The third row is not a typo. The folder matches the injection string just as readily as a real id, which is the reason for the last section of this lesson.
Now to the page itself. We’ll build this file in three passes across the next few sections, because two parts, the await and the validation, each need a section of their own. Here is the first pass: capture the id and render it, nothing more.
export default function InvoicePage({ params }) { return <h1>Invoice {params.id}</h1>;}A few things to read off that file, picking up from the last lesson. It’s a Server Component, which is the default and needs no directive. It’s a default export, because page.tsx is one of the handful of files where the framework requires a default export. The component is named InvoicePage, but that name has no effect on routing: the route is decided entirely by the folder path, not by what you call the function. You could name it Banana and the route would be identical. If you’re carrying a habit from elsewhere where the filename or the export name decides the route, set it aside here. For App Router pages, the folder is the route and the export is just the function that renders.
One build-time error is worth knowing now, because it’s confusing the first time you hit it. Two dynamic folders with the same name on one path crash the build. You can’t have app/invoices/[id]/comments/[id]/page.tsx, because both [id] segments would want to write params.id, and Next.js refuses to compile rather than guess which one wins. So the segment names have to be unique along any single route. A [id] folder sitting next to a [...id] folder at the same level is the same kind of conflict, which we’ll return to with catch-alls.
Why params is a Promise, and how to type it
Section titled “Why params is a Promise, and how to type it”This is the part people most often get wrong on first contact, so we’ll slow down. In Next.js 16, params is not the plain object the table above implied. It’s a Promise of that object, and your page has to await it.
Why would the framework hand you a Promise instead of the values directly? Because params is a request-time value: it doesn’t exist until a real request for a real URL comes in. Making it a Promise lets Next.js start rendering the static parts of your page, the parts that are identical for every invoice, before the request-specific data is resolved, then stream the dynamic parts in as they become ready. That’s the Cache Components rendering model, the subject of a later chapter on rendering and caching, and you don’t need to understand it to use dynamic routes today. The rule you do need is short: params is a Promise, so await it. It isn’t the only one. In Next.js 16, searchParams and the request functions cookies(), headers(), and draftMode() are all Promises for the same reason. You’ll meet those later, and the await is identical.
So how do you type that Promise? You could write the shape by hand, but the modern way, and the course’s default, is a helper Next.js generates for you. Compare the two:
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const { id } = await params; return <h1>Invoice {id}</h1>;}Derived from the route, it autocompletes the param names and can’t drift when you rename the folder. This is the default. No import needed: Next.js generates the type and makes it globally available.
export default async function InvoicePage({ params,}: { params: Promise<{ id: string }>;}) { const { id } = await params; return <h1>Invoice {id}</h1>;}This is what the helper expands to. It works, but you restate the shape by hand, and it goes stale the day someone renames the [id] folder. The Promise that the helper hides is right there in the annotation.
The hand-written version is here only so you can see the Promise in the type, since the helper hides it. Once you’ve seen it, the helper is what you’ll use. PageProps<'/invoices/[id]'> takes the route literal as a string and gives you back the exact props for that page, with the param names autocompleted from the folder structure. You write less, the type can’t disagree with the folder name, and the day you rename [id] to [invoiceId] the type updates with it, while the hand-written annotation keeps claiming id. Reach for PageProps every time.
Now the same file as a complete but still unvalidated page, walked one piece at a time so the mechanics are clear:
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const { id } = await params;
return <h1>Invoice {id}</h1>;}This is src/app/invoices/[id]/page.tsx. The page is async and typed with the generated PageProps helper, derived from the route literal. Because it’s a Server Component, awaiting inside the render is allowed and normal, with no useEffect and no fetch-on-mount.
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const { id } = await params;
return <h1>Invoice {id}</h1>;}This is the line that’s easy to forget. params is a Promise, so await unwraps it into the plain object before destructuring pulls id out. Drop the await and id is itself a Promise: TypeScript flags params.id because that property doesn’t exist on a Promise, and at runtime you’d render [object Promise] on the page.
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const { id } = await params;
return <h1>Invoice {id}</h1>;}With id resolved to a string, the render uses it like any other value. From here on id is the captured URL part, the text that filled the [id] slot, ready to be checked and used.
The orange step is the one to remember. Reading params.id without await is the dynamic-routes equivalent of forgetting a key on a list: it catches you out the first time, TypeScript points it out right away, and once awaiting is a habit it stops being a problem.
Reading params in a Client Component
Section titled “Reading params in a Client Component”The whole story above assumes a Server Component, which is where your pages should live by default. Occasionally, though, the page or a piece of it has to be a Client Component, because it needs state, an event handler, or a browser API. A Client Component can’t be async, so it can’t await. It reads the very same Promise with React’s use hook instead:
'use client';
import { use } from 'react';
export default function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const { id } = use(params); return <h1>Invoice {id}</h1>;}That’s the same use(promise) pattern from the Server/Client lesson: a Client Component takes the Promise as a prop and unwraps it with use() where a Server Component would await. There’s also a useParams() hook for grabbing params without threading the prop down, though you should reach for it sparingly. The server path is the main route through this lesson, and this is the escape hatch for when a page genuinely has to run on the client.
A quick check before moving on, since this is the concept that has to stick:
This page reads like it should work, yet TypeScript rejects the marked line. On the line const id = params.id, what is params.id actually reaching for?
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const id = params.id; return <h1>Invoice {id}</h1>;}Promise wrapper simply doesn’t have — so it resolves to nothing, and the type checker flags exactly that.params most recently.params is a Promise, not the plain object. A Promise has no id property, so params.id is undefined at runtime and a type error at compile time. Unwrap it first — const { id } = await params in a Server Component, or use(params) in a Client Component.Scoping a route with multiple dynamic segments
Section titled “Scoping a route with multiple dynamic segments”One unknown is the common case, but real SaaS URLs usually carry two. An invoice doesn’t just have an id; it belongs to an organization, and the org is part of the path: /orgs/acme/invoices/inv_42. That’s the multi-tenant shape you’ll see for the rest of the course, with the tenant in the URL and the resource within it. Mechanically it’s nothing new: you just nest bracketed folders.
Directorysrc/
Directoryapp/
Directoryorgs/
Directory
[orgSlug]/capturesparams.orgSlugDirectoryinvoices/
Directory
[id]/capturesparams.id- page.tsx
That tree produces /orgs/:orgSlug/invoices/:id, and params now carries both names: { orgSlug: string; id: string }. The rule that each segment name must be unique along the route is exactly why you couldn’t write two [id]s. Here the two segments have distinct names, so they coexist cleanly. One await still unwraps the whole object:
export default async function InvoicePage({ params,}: PageProps<'/orgs/[orgSlug]/invoices/[id]'>) { const { orgSlug, id } = await params;
return <h1>Invoice {id} in {orgSlug}</h1>;}This is where the PageProps helper earns its keep. Feed it the full route literal and it autocompletes both orgSlug and id, and if you ever restructure the path, the type follows. Hand-writing Promise<{ orgSlug: string; id: string }> for every nested page is the kind of busywork that drifts the moment the folders change. Beyond the extra name, there’s no new idea here: it’s the single-segment page with one more value in the same object.
Variable-depth URLs: catch-all and optional catch-all
Section titled “Variable-depth URLs: catch-all and optional catch-all”Everything so far had a known shape: one segment, or two, and you knew exactly how many. Some URLs don’t work that way. A documentation site has /docs/getting-started, /docs/guides/routing, and /docs/guides/routing/dynamic-segments, the same kind of page at one, two, or three levels deep, and you can’t predict the depth ahead of time. A CMS path or an editorial slug is the same. So the examples below switch domains, from invoices to docs: when the depth is unknown, the brackets grow an ellipsis.
A folder named app/docs/[...slug]/page.tsx is a catch-all segment . The three dots mean “match this segment and every segment after it.” /docs/a, /docs/a/b, and /docs/a/b/c, at any depth, all resolve here, and params.slug is no longer a string but a string[], one entry per segment. There’s one sharp edge that people get wrong: a catch-all does not match the parent on its own. /docs alone is a 404, because there’s nothing for the catch-all to capture.
An optional catch-all closes that gap. Double the brackets, as in app/docs/[[...slug]]/page.tsx, and you have an optional catch-all segment : everything the catch-all matches, plus the bare parent. At /docs, params.slug is undefined, and the type widens to string[] | undefined; at /docs/a it’s ['a'] exactly as before. You reach for this when one page serves both the index and its depth-N children, such as a docs home that renders a landing page when there’s no slug and an article when there is.
The single row that differs between the two is the whole distinction, so here they are side by side:
params.slug[...slug] [...slug] [...slug] [...slug] [...slug] captures one or more trailing segments into a string[]. The bare /docs has nothing to capture, so it doesn’t match and returns a 404. Flip the tab and every row below stays identical.
params.slug[[...slug]] [[...slug]] [[...slug]] [[...slug]] Double the brackets and the bare /docs now matches too, with params.slug as undefined. Only the top row changed. From one segment down, the two variants are the same.
The code barely changes between the two. The catch-all page works through the array, while the optional catch-all first checks whether the array is there at all:
export default async function DocsPage({ params,}: PageProps<'/docs/[...slug]'>) { const { slug } = await params;
return <Article path={slug.join('/')} />;}slug is always an array, so iterate it. There’s no index page to handle, because /docs never reaches this file. slug.join('/') rebuilds the path from ['guides', 'routing'] back into guides/routing.
export default async function DocsPage({ params,}: PageProps<'/docs/[[...slug]]'>) { const { slug } = await params;
if (slug === undefined) return <DocsIndex />; return <Article path={slug.join('/')} />;}Same page, but slug can be undefined at /docs, so branch on it to render the index. Past that one added line, the article path is built exactly as on the left.
Two edges to keep in mind. First, a catch-all value is always an array, even for a single segment. /docs/intro gives you ['intro'], not 'intro'. Treat slug as a string and you’ll get type errors and surprising joins; when you want the first piece, reach for slug[0]. Second, a catch-all and a plain [id] can’t be siblings at the same level. app/docs/[...slug] next to app/docs/[id] is the same build-time conflict as two [id]s, because the framework can’t decide which one owns /docs/intro. So use one dynamic shape per level.
Picking the right bracket shape
Section titled “Picking the right bracket shape”You now have four bracket shapes, and the real skill is not remembering their syntax but choosing the right one. That choice comes down to a short series of questions asked in a fixed order, and the order itself is what matters. Walk it from the top.
When the values are a fixed, enumerable set, make a folder per value (settings/billing/, settings/team/). There’s no params to validate, and the routes are fully type-safe. Don’t introduce a [segment] for three known pages.
The canonical detail page: app/invoices/[id]/page.tsx. params.id is a string. This is the ninety-percent case.
Multi-axis identity, the tenant-scoped resource: app/orgs/[orgSlug]/invoices/[id]/page.tsx. One await, two values, and names must be unique.
Variable depth where the bare parent is not this page. params.slug is string[], and /docs returns a 404. Reach for it for nested content trees that have a separate index route.
Variable depth where the bare parent is this page. params.slug is string[] | undefined, and /docs renders the index. One page covers both the landing and the depth-N article.
The order earns its place here: settle known-versus-variable depth before anything else, and only inside “variable” ask the parent question. Once you’ve walked it a couple of times it collapses into a single rule you can keep in your head:
The URL is untrusted input: validate before you query
Section titled “The URL is untrusted input: validate before you query”Back to that third table row from the start, /invoices/' OR 1=1 --. This is the part a working tutorial usually skips, and it’s what turns a page that demos into one you can ship. params.id is whatever the user typed in the address bar. The URL bar is an input field, every bit as untrusted as a <textarea>, and the value arrives with no guarantees. The id might be the wrong shape entirely, it might quietly become NaN after a careless Number(), it might be a hostile string probing for a hole, or it might be a well-formed id for a record that simply doesn’t exist. Handing any of those straight to a query is the same class of mistake as trusting a raw form field.
The reflex is three beats, in order: capture, validate, query. You already have capture. Validate means that before the id touches anything, you run it through a schema that says what a valid id actually looks like, and bail out cleanly if it doesn’t fit. You’ve met Zod earlier in the course, so this is just applying it at a new boundary. The course standardizes on UUIDv7 for entity ids, so the realistic check is z.uuid(), and the bail-out is notFound(), imported from next/navigation. Here is how it looks in one file.
This is src/app/invoices/[id]/page.tsx in its shippable form. The imports are left out so the eye stays on the gate: z from zod, notFound from next/navigation, and getInvoice from your data layer. Here it is one step at a time.
const paramsSchema = z.object({ id: z.uuid() });
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
const invoice = await getInvoice(parsed.data.id);
return <InvoiceDetail invoice={invoice} />;}The recap from the last two passes: the async page, typed with PageProps<'/invoices/[id]'>, and await params to unwrap the Promise. Nothing new here, except that the id coming out of that await is an untrusted string, exactly what the user typed in the address bar.
const paramsSchema = z.object({ id: z.uuid() });
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
const invoice = await getInvoice(parsed.data.id);
return <InvoiceDetail invoice={invoice} />;}The validation gate. The schema on line 1 says what a valid id is: a UUID. safeParse runs the awaited value against it and returns a result object instead of throwing. This is the single doorway the untrusted string has to pass through before it can touch anything.
const paramsSchema = z.object({ id: z.uuid() });
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
const invoice = await getInvoice(parsed.data.id);
return <InvoiceDetail invoice={invoice} />;}This is the path a bad id takes. If the id isn’t a valid UUID, notFound() throws a signal that the framework catches to render the nearest not-found.tsx, an HTTP 404. Because it throws, nothing after this line runs, so the bad value never reaches the query. Don’t wrap it in a try/catch, or you’d swallow the very signal you’re relying on.
const paramsSchema = z.object({ id: z.uuid() });
export default async function InvoicePage({ params,}: PageProps<'/invoices/[id]'>) { const parsed = paramsSchema.safeParse(await params); if (!parsed.success) notFound();
const invoice = await getInvoice(parsed.data.id);
return <InvoiceDetail invoice={invoice} />;}Only past the gate does the value reach the query. parsed.data is the narrowed, validated shape, so parsed.data.id is a real UUID, safe to hand to getInvoice. The query is a stub here; the boundary is the point, not the SQL.
Three points are worth nailing down from that file. The validation happens before the query, never after, because checking the id once it’s already hit the database defeats the purpose. safeParse is the right Zod call for untrusted input because it returns a { success, data } | { success, error } result instead of throwing, which lets you decide what a failure means; here it means a 404. And notFound() is the right failure: a missing or garbage resource id should resolve to “this invoice doesn’t exist” (404), not crash the request with a 500. The full notFound() and not-found.tsx story, covering custom 404 pages and error boundaries, is the subject of the next chapter on async UI. Here you’re only calling it.
Coercion has a quieter failure mode worth flagging. Number(params.id) on a non-numeric string gives you NaN, not an error: it fails silently and your query gets garbage. If your ids were numeric (they aren’t in this course, but you’ll meet codebases where they are), you’d reach for z.coerce.number().int().positive(), which turns the URL string into a number and rejects the ones that don’t convert, in one step. Let the schema do the coercing, and never trust a bare Number().
One lighter-weight variant is worth recognizing. When the param is a small fixed set, such as a locale like en, es, or fr, the official docs often skip Zod and use a plain type guard instead: a function like assertValidLocale(slug) that narrows the type when the value is in the allowed set and calls notFound() when it isn’t. That’s a fine, lean choice for enumerable params. Zod stays the default the moment the value has a shape to parse: a UUID, a coerced number, anything with structure.
Now wire the boundary yourself:
Complete the validation gate so an invalid id resolves to a 404, not a crash. Pick the right option from each dropdown, then press Check.
const paramsSchema = z.object({ id: z.uuid() });
export default async function InvoicePage({ params }: PageProps<'/invoices/[id]'>) { const parsed = paramsSchema.___(await params); if (!parsed.success) ___();
const invoice = await getInvoice(parsed.___.id); return <InvoiceDetail invoice={invoice} />;}generateStaticParams, in one breath
Section titled “generateStaticParams, in one breath”You’ll see one more export on real dynamic pages. It’s worth recognizing now so it doesn’t confuse you later, though teaching it isn’t this lesson’s job. A dynamic route can be pre-rendered at build time by exporting a generateStaticParams() function that returns an array of params objects, like [{ id: '1' }, { id: '2' }]. The framework generates a static HTML file per entry ahead of time, and any unlisted id is rendered on demand. It’s how content catalogs and docs sites turn known slugs into static pages. The full treatment, and how it interacts with the rendering and caching model, lands in a later chapter on static generation. For now, when you spot generateStaticParams in a [slug] page, you know what it’s for.
Putting it together
Section titled “Putting it together”The core skill from this lesson is small and you’ll use it on every route you ever write: read the brackets, know the params type. Prove it to yourself by matching each folder shape to the exact shape it produces:
Match each route folder to the shape of the `params` it hands the page. Click an item on the left, then its match on the right. Press Check when done.
[id]{ id: string }[orgSlug]/[id]{ orgSlug: string; id: string }[...slug]{ slug: string[] }[[...slug]]{ slug?: string[] }That’s dynamic routing. A bracketed folder is a typed hole in the URL: the framework fills params for you the way it fills children, but unlike children, the contents are text an attacker can type. So the reflex is always capture, validate, query, with the bracket shape chosen by how much of the URL you don’t know in advance. Keep the one-line decision summary handy: known single → [id]; known multi-axis → nested; variable without a parent → [...slug]; variable with a parent → [[...slug]]; enumerable → literal folders.
You can now build these routes. The next thing you’ll want is to navigate to them: typed links to /invoices/[id], programmatic redirects, and the rest of the <Link> and router story, which is exactly the next lesson.
External resources
Section titled “External resources”The canonical route → params tables for [slug], [...slug], and [[...slug]], straight from the source.
How the global PageProps helper types params and searchParams from a route literal — no import, can't drift.
parse vs safeParse for untrusted input: why safeParse returns a result object instead of throwing — the validation gate from this lesson.