Skip to content
Chapter 29Lesson 3

Dynamic and catch-all segments

The Next.js App Router's bracketed folders, where one page serves a family of URLs by capturing path parts as typed route parameters.

Every invoice in your app needs a detail page. There are forty today and a thousand by next quarter, so you won’t write a page.tsx for each id. So far the file system has been a static route table: one folder, one URL. This lesson shows how a single 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 serve a docs-style URL whose depth you don’t know in advance. You’ll also learn the habit that keeps such pages safe: never pass an id from the address bar to your database before checking it. The catch-all tools at the end look powerful and get reached for too often, but they fit a narrow set of cases. A plain [id] covers ninety percent of them.

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 name is mechanical: the text inside the brackets becomes the property name, so [id] gives you params.id and [invoiceId] gives you params.invoiceId. The captured value is always a string, because a URL holds only characters a user can type, never numbers.

Here is what lands in params for a few URLs hitting app/invoices/[id]/page.tsx:

URL
params
/invoices/ inv_42
captured by [id]
{ id: 'inv_42' }
/invoices/ 8f3a-4c…
captured by [id]
{ id: '8f3a-4c…' }
/invoices/ ' OR 1=1 --
captured by [id]
{ id: "' OR 1=1 --" } untrusted
One `page.tsx`, three URLs. The bracketed folder captures whatever fills its slot, and the captured value is just a string, so a SQL-injection probe lands in `params.id` exactly like a friendly id does.

The third row is the point: the folder matches an injection string as readily as a real id, which is why this lesson ends with validation.

We’ll build the page in three passes, since the await and the validation each need their own section. The first pass captures the id and renders it, nothing more:

src/app/invoices/[id]/page.tsx
export default function InvoicePage({ params }) {
return <h1>Invoice {params.id}</h1>;
}

A few things to read off that file. It’s a Server Component, the default, so it needs no directive. It’s a default export, because page.tsx is one of the files where the framework requires one. The component is named InvoicePage, but the name has no effect on routing: the route comes entirely from the folder path, not from what you call the function.

One build-time error is worth knowing now. 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 write params.id, and Next.js refuses to compile rather than guess which wins. Segment names have to be unique along any single route. A [id] folder next to a [...id] folder at the same level is the same 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”

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 a Promise instead of the values directly? Because params is a request-time value: it doesn’t exist until a request for a real URL arrives. Wrapping it in a Promise lets Next.js render the static parts of the page, the parts identical for every invoice, before the request-specific data resolves. The rule you need is short: params is a Promise, so await it. searchParams and the request functions cookies(), headers(), and draftMode() are Promises for the same reason, and the await is identical.

To type that Promise you could write the shape by hand, but the 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.

Use the hand-written version only to see the Promise the helper hides. Otherwise reach for PageProps every time: you write less, the type can’t disagree with the folder name, and renaming [id] to [invoiceId] updates the type with it while a hand-written annotation keeps claiming id.

Here is the same file as a complete but still unvalidated page, one piece at a time:

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. Because it’s a Server Component, awaiting inside the render is 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>;
}

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].

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 id is the captured URL part, the text that filled the [id] slot, ready to be checked and used.

1 / 1

The orange step is the one to remember: reading params.id without await is a type error TypeScript points out right away, and once awaiting is a habit it stops being a problem.

Your pages should be Server Components by default. Occasionally a 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 same Promise with React’s use hook instead:

src/app/invoices/[id]/page.tsx
'use client';
import { use } from 'react';
export default function InvoicePage({ params }: PageProps<'/invoices/[id]'>) {
const { id } = use(params);
return <h1>Invoice {id}</h1>;
}

This is the same use(promise) pattern from the Server/Client lesson: the Client Component takes the Promise as a prop and unwraps it with use() where a Server Component would await. A useParams() hook also reads params without threading the prop down, but reach for it sparingly. The server path is the main route through this lesson; this is the escape hatch for when a page has to run on the client.

A quick check before moving on:

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>;
}
A property the Promise wrapper simply doesn’t have — so it resolves to nothing, and the type checker flags exactly that.
The captured id string, identical to what you’d get one line later after unwrapping it.
Leftover data from whichever request populated params most recently.

One unknown is common, but real SaaS URLs usually carry two: an invoice has an id and belongs to an organization, and both sit in the path: /orgs/acme/invoices/inv_42. This is the multi-tenant shape you’ll use for the rest of the course, the tenant in the URL and the resource within it. To build it, you nest bracketed folders.

  • Directorysrc/
    • Directoryapp/
      • Directoryorgs/
        • Directory[orgSlug]/ captures params.orgSlug
          • Directoryinvoices/
            • Directory[id]/ captures params.id
              • page.tsx

That tree produces /orgs/:orgSlug/invoices/:id, and params now carries both names: { orgSlug: string; id: string }. Each segment name must be unique along the route, which is why you couldn’t write two [id]s; here the names differ, so they coexist. One await still unwraps the whole object:

src/app/orgs/[orgSlug]/invoices/[id]/page.tsx
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 PageProps pays off. Feed it the full route literal and it autocompletes both orgSlug and id, and the type follows if you restructure the path. Hand-writing Promise<{ orgSlug: string; id: string }> for every nested page is busywork that drifts the moment the folders change.

Catch-all and optional catch-all: matching unknown depth

Section titled “Catch-all and optional catch-all: matching unknown depth”

Everything so far had a known shape: one segment, or two, and you knew exactly how many. Some URLs don’t. 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, with no way to predict the depth ahead of time. 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 all resolve here, and params.slug is no longer a string but a string[], one entry per segment. Here is where people get it 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'] as before. 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.

One row separates the two variants. Here they are side by side:

URL
params.slug
/docs
[...slug]
404 · no match
/docs / a
[...slug]
['a']
/docs / a / b
[...slug]
['a', 'b']
/docs / a / b / c
[...slug]
['a', 'b', 'c']

[...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.

The code barely changes between the two. The catch-all page works through the array; the optional catch-all first checks whether the array is there at all:

src/app/docs/[...slug]/page.tsx
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, turning ['guides', 'routing'] back into guides/routing.

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, so reach for slug[0] when you want the first piece. 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. Use one dynamic shape per level.

The skill isn’t recalling the syntax of the four bracket shapes; it’s picking the right one. That decision is a short series of questions asked in a fixed order.

Which bracket shape?

The order matters: settle known-versus-variable depth first, and only inside “variable” ask the parent question. After a couple of passes it collapses into one rule:

Capture, validate, query: the URL is untrusted input

Section titled “Capture, validate, query: the URL is untrusted input”

Back to that third table row from the start, /invoices/' OR 1=1 --. params.id is whatever the user typed in the address bar, every bit as untrusted as a <textarea>. The id might be the wrong shape, it might quietly become NaN after a careless Number(), it might be a hostile string probing for a hole, or it might be well-formed but name a record that doesn’t exist. Handing any of those straight to a query is the same mistake as trusting a raw form field.

The reflex is three beats, in order: capture, validate, query. You already have capture. Validate means running the id through a schema that says what a valid id looks like, and bailing out cleanly if it doesn’t fit. The course standardizes on UUIDv7 for entity ids, so the check is z.uuid() and the bail-out is notFound(), imported from next/navigation.

This is src/app/invoices/[id]/page.tsx in its shippable form, imports omitted.

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. 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 passes 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} />;
}

The path a bad id takes. If the id isn’t a valid UUID, notFound() throws a signal 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 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 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.

1 / 1

Three things make this gate work. Validation happens before the query, never after, since checking the id once it has hit the database defeats the purpose. safeParse 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 garbage resource id should resolve to “this invoice doesn’t exist” (404), not crash the request with a 500. Custom 404 pages and not-found.tsx are the next chapter; here you only call notFound().

Coercion has a quieter failure mode. 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 some codebases use them), you’d reach for z.coerce.number().int().positive(), which converts the URL string to a number and rejects the ones that don’t convert. Let the schema coerce; never trust a bare Number().

One lighter variant: when the param is a small fixed set, such as a locale (en, es, fr), a plain type guard does the job. A function like assertValidLocale(slug) narrows the type when the value is in the allowed set and calls notFound() when it isn’t. 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} />;
}

One skill carries every route you write: read the brackets, know the params type. Match each folder shape to the params 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[] }

You can now build these routes. Next you’ll navigate to them with <Link> and the router.