Dynamic Segments — [slug]

Captures a URL path segment and passes its value to the route through the params Promise.

Signature
app/blog/[slug]/page.js        params: Promise<{ slug: string }>
app/shop/[...slug]/page.js     params: Promise<{ slug: string[] }>
app/shop/[[...slug]]/page.js   params: Promise<{ slug?: string[] }>

Parameters

ParameterTypeDescription
$[folderName]stringA single Dynamic Segment. app/blog/[slug]/page.js matches /blog/a and resolves to { slug: 'a' }. It does not match /blog on its own.
$[...folderName]string[]Catch-all segment. app/shop/[...slug]/page.js matches /shop/a, /shop/a/b and deeper, giving { slug: ['a', 'b'] }. It does not match /shop itself.
$[[...folderName]]optionalstring[] | undefined= undefinedOptional catch-all. Matches everything the catch-all form does plus the parameterless route: /shop resolves to { slug: undefined }.

Return value

Promise<{ [key: string]: string | string[] | undefined }>A promise resolving to an object keyed by segment name. Values are typed string, string[] or undefined because they are only known at runtime.

Errors

The build fails when sibling dynamic segments in the same position use different names, for example app/[slug] alongside app/[id].

Changelog

  • PHP 15.0.0params became a Promise. Synchronous access still worked for backwards compatibility, with a deprecation warning.
  • PHP 16.0.0Synchronous params access removed. params must be awaited, or unwrapped with React's use() in a Client Component.

Wrap a folder name in square brackets and that path position becomes a variable. Next.js hands the captured value to layout, page, route and generateMetadata as the params prop; Client Components anywhere in the tree can read the same values through useParams().

Choosing a form

  • [slug] for exactly one segment — a post, a product, a user id.
  • [...slug] when the depth is unknown, such as a nested docs tree or a CMS path.
  • [[...slug]] when the same page must also serve the bare parent URL, which the plain catch-all does not match.

What trips people up

params is a promise. Awaiting it at the top of a layout is the common mistake — it blocks the layout from prerendering. Pass the promise down and await it in the component that actually needs the value.

The types are deliberately broad, because anyone can type any URL into the address bar. A [locale] segment is typed string, not your union of language codes; validate at runtime and call notFound() for values you do not recognise.

Example

Example · typescript
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <div>My Post: {slug}</div>
}

// /blog/a  ->  My Post: a
// /blog/b  ->  My Post: b

When to use it

  • Serve every blog post from one file using the slug in the URL.
  • Match an arbitrarily deep documentation path with a single catch-all route.
  • Handle /shop and /shop/clothes/tops in the same page with an optional catch-all.
  • Read the current product id inside a deeply nested Client Component.

Frequently asked questions

What is the difference between [...slug] and [[...slug]] in Next.js?

The optional form [[...slug]] also matches the route without any parameter — /shop as well as /shop/a/b — where [...slug] requires at least one segment. In the optional form, params.slug is undefined for the bare parent URL.

How do I get the URL parameter in a Next.js App Router page?

Destructure the params prop and await it: const { slug } = await params. In a Client Component page use React's use(params); elsewhere in the client tree use the useParams hook from next/navigation.

Why do I get an error reading params.slug directly?

Because params is a Promise, not a plain object. Synchronous access was deprecated in Next.js 15 and removed in 16, so property access on the promise no longer returns the value.

More examples

Catch-all segment with prerendered paths

The value is always an array, even for a single segment — slug[0] not slug. The last comment line is the distinction people hit in production: a plain catch-all never matches the parent URL, so /shop 404s until the folder is renamed to [[...slug]], at which point the same request resolves to { slug: undefined }.

Example · typescript
// app/shop/[...slug]/page.tsx
export async function generateStaticParams() {
  return [{ slug: ['clothes'] }, { slug: ['clothes', 'tops'] }]
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>
}) {
  const { slug } = await params
  return <nav>{slug.join(' / ')}</nav>
}

// /shop/a        ->  { slug: ['a'] }
// /shop/a/b      ->  { slug: ['a', 'b'] }
// /shop/a/b/c    ->  { slug: ['a', 'b', 'c'] }
// /shop          ->  404 (use [[...slug]] to match it)

Reading params in a Client Component

A Client Component cannot be async, so the promise is unwrapped with React's use() instead of await. This works when params arrives as a prop on the page itself; a Client Component further down the tree that never receives the prop should call useParams() from next/navigation instead.

Example · typescript
'use client'
import { use } from 'react'

export default function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = use(params)

  return (
    <div>
      <p>{slug}</p>
    </div>
  )
}

Narrowing a broad param type at runtime

params values are typed string, string[] or undefined regardless of how few values are actually valid, because a user can put anything in the address bar. The assertion function converts that runtime check into a compile-time narrowing, so the rest of the file works with the union type. PageProps<'/[locale]'> is generated by next typegen.

Example · typescript
// app/[locale]/page.tsx
import { notFound } from 'next/navigation'
import type { Locale } from '@i18n/types'
import { isValidLocale } from '@i18n/utils'

function assertValidLocale(value: string): asserts value is Locale {
  if (!isValidLocale(value)) notFound()
}

export default async function Page(props: PageProps<'/[locale]'>) {
  const { locale } = await props.params // locale is typed as string
  assertValidLocale(locale)
  // locale is now typed as Locale
}

Discussion

  • Be the first to comment on this lesson.