Type something to search...
Next.js Dynamic Route Segments

Next.js Dynamic Route Segments

Most of the routes in a real application aren't known ahead of time. A blog doesn't have a finite list of URLs baked into its file structure — it has one shape, /blog/[something], and a database full of somethings. Next.js handles this with Dynamic Segments: a folder-naming convention that turns a piece of a URL path into a variable your code can read, instead of a literal string you'd otherwise have to hardcode one file per value.

If you've built routing in almost any other framework, the concept itself won't surprise you. What's worth paying attention to here is how Next.js threads that captured value through to your components as a promise rather than a plain object, and how that interacts with the newer Cache Components model — that's the part that trips people up who learned this API in an older version of Next.js.

The Basic Convention

Wrap a folder's name in square brackets to make it a Dynamic Segment: [folderName]. A blog post route, for example, becomes app/blog/[slug]/page.tsx:

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

Every URL that matches the shape /blog/<anything> resolves to this one file, with whatever the visitor typed as <anything> captured into params.slug:

RouteExample URLparams
app/blog/[slug]/page.js/blog/a{ slug: 'a' }
app/blog/[slug]/page.js/blog/b{ slug: 'b' }
app/blog/[slug]/page.js/blog/c{ slug: 'c' }

That same captured value gets passed as params to layout.js, page.js, route.js, and generateMetadata at that segment — anywhere Next.js needs to know which specific instance of the route is being requested.

One easy-to-miss detail: dynamic segments defined before the root layout — as they would be in app/[lang]/layout.tsx for an internationalized app — are called root parameters, and they get special treatment. Because the root layout sits above every other segment, a normal params prop can't reach it from deeper components without threading it down manually. Root parameters solve that by being readable from any Server Component via next/root-params, regardless of how deep in the tree that component lives.

params Is a Promise — Not a Plain Object

This is the single most important behavioral fact on this page, and it's a breaking change from Next.js 14 and earlier, where params was synchronous. In current versions, you have to await it (or use React's use() hook) before reading any field off it:

export default async function Page({ params }) {
  const { slug } = await params; // not params.slug directly
  return <div>{slug}</div>;
}

Next.js still lets you access params synchronously for backward compatibility, but that path is deprecated and will eventually be removed — don't build new code around it. If you're maintaining an older codebase mid-migration, the official codemod for this exact change exists precisely because so many projects have this pattern scattered across dozens of files.

Reading params in Client Components

A page marked 'use client' can't be an async function, so it can't await params directly. React's use() API handles this instead:

"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>
  );
}

If you need to read params from somewhere deeper in a Client Component tree — not just the top-level page component — reach for the useParams hook instead, which works anywhere in that tree without needing the promise passed down as a prop.

Catch-All and Optional Catch-All Segments

A single [slug] captures exactly one path segment. Sometimes you want a route to swallow an arbitrary number of segments — a nested category tree, a documentation site with deep folder structures. Add an ellipsis inside the brackets: [...slug].

app/shop/[...slug]/page.js

matches /shop/clothes, /shop/clothes/tops, /shop/clothes/tops/t-shirts, and so on indefinitely — with each matched segment captured as an array:

RouteExample URLparams
app/shop/[...slug]/page.js/shop/a{ slug: ['a'] }
app/shop/[...slug]/page.js/shop/a/b{ slug: ['a', 'b'] }
app/shop/[...slug]/page.js/shop/a/b/c{ slug: ['a', 'b', 'c'] }

Notice what's not in that table: /shop on its own, with zero additional segments, doesn't match a plain catch-all. If you need that base route to also resolve to the same component, wrap the ellipsis in a second set of brackets — [[...slug]] — to make it optional:

RouteExample URLparams
app/shop/[[...slug]]/page.js/shop{ slug: undefined }
app/shop/[[...slug]]/page.js/shop/a{ slug: ['a'] }

The distinction is small in the file name but meaningful in behavior: a plain catch-all requires at least one segment after the base path; the optional variant also matches the base path with no trailing segments at all. Get this wrong and you'll either see an unexpected 404 on your category root, or an unexpected match you didn't intend.

Typing params Correctly

Because a visitor can type literally anything into the address bar, TypeScript can't narrow params fields down to a specific string literal union — they're typed broadly as string, string[], or undefined for optional catch-alls:

Routeparams Type Definition
app/blog/[slug]/page.js{ slug: string }
app/shop/[...slug]/page.js{ slug: string[] }
app/shop/[[...slug]]/page.js{ slug?: string[] }
app/[categoryId]/[itemId]/page.js{ categoryId: string, itemId: string }

Rather than hand-writing these prop types yourself, use the generated PageProps<'/route'>, LayoutProps<'/route'>, or RouteContext<'/route'> helpers, which infer the correct shape directly from your route's actual file-system position. These are generated automatically during next dev, next build, or next typegen, and become globally available without an explicit import — a detail easy to miss if you're used to manually authoring prop interfaces.

If your route only makes sense for a known, fixed set of values — say, a [locale] segment that should only ever be en, fr, or de — the broad string type is technically correct but not especially useful day to day. The recommended pattern is to validate at the boundary and narrow from there:

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; // typed as string
  assertValidLocale(locale);
  // locale is now typed as Locale for the rest of this function
}

This pattern — validate once, narrow the type, trust it downstream — is worth internalizing generally for any dynamic segment with a known-valid set of values, not just locales.

Dynamic Segments Under Cache Components

This is where dynamic segments intersect with the newer Cache Components rendering model, and it's genuinely the trickiest part of this whole convention to get right. The behavior forks depending on whether you've defined generateStaticParams for the route.

Without generateStaticParams

If you haven't told Next.js which param values to prerender, every value is runtime data by definition — the build process has no idea what slugs will show up in production. Any component that reads params in this situation must be wrapped in a <Suspense> boundary, or the build will refuse to proceed:

import { Suspense } from "react";

export default function Page({ params }: PageProps<"/blog/[slug]">) {
  return (
    <div>
      <h1>Blog Post</h1>
      <Suspense fallback={<div>Loading...</div>}>
        {params.then(({ slug }) => (
          <Content slug={slug} />
        ))}
      </Suspense>
    </div>
  );
}

async function Content({ slug }: { slug: string }) {
  const res = await fetch(`https://api.vercel.app/blog/${slug}`);
  const post = await res.json();
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </article>
  );
}

A subtle rule buried in the docs but easy to violate: in a layout, don't await params at the top level, even inside a Suspense boundary further down. Doing so blocks the entire layout from being part of the prerendered static shell. Instead, pass the params promise down as a prop to whichever component actually needs the resolved value, and await it there — keep the layout itself synchronous with respect to params.

With generateStaticParams

If you do provide sample values, Next.js prerenders each one at build time and validates, during the build itself, that your dynamic content and runtime API usage is handled correctly for those specific values:

import { Suspense } from "react";

export async function generateStaticParams() {
  return [{ slug: "1" }, { slug: "2" }, { slug: "3" }];
}

export default async function Page({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  return (
    <div>
      <h1>Blog Post</h1>
      <Content slug={slug} />
    </div>
  );
}

async function Content({ slug }: { slug: string }) {
  const post = await getPost(slug);
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
    </article>
  );
}

async function getPost(slug: string) {
  "use cache";
  const res = await fetch(`https://api.vercel.app/blog/${slug}`);
  return res.json();
}

Here's the gotcha that's easy to miss entirely: build-time validation only covers the code paths your sample params actually exercise. If a route has conditional logic that only reaches a runtime API (like cookies()) for param values you never included in generateStaticParams, that branch is completely unvalidated at build time — it will pass the build and then fail (or need to be validated) on the first real request:

import { cookies } from "next/headers";

export async function generateStaticParams() {
  return [{ slug: "public-post" }, { slug: "hello-world" }];
}

export default async function Page({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;

  if (slug.startsWith("private-")) {
    // This branch never runs during the build — no sample slug hits it
    return <PrivatePost slug={slug} />;
  }

  return <PublicPost slug={slug} />;
}

For any private-* slug, validation happens on the first request instead of at build time — and because PrivatePost reads cookies() without a Suspense boundary, that first request fails. The fix is exactly what you'd expect from the "without generateStaticParams" case above: wrap the untested branch in its own <Suspense> boundary so runtime data access there is explicitly allowed:

if (slug.startsWith("private-")) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <PrivatePost slug={slug} />
    </Suspense>
  );
}

The practical lesson: if your dynamic route has conditional branches that behave differently based on the param value, make sure your generateStaticParams samples exercise every branch — not just the common case — or wrap the untested branches defensively in Suspense from the start.

Generating Routes at Build Time

generateStaticParams is also how you turn what would otherwise be an on-demand, request-time render into a build-time prerender:

export async function generateStaticParams() {
  const posts = await fetch("https://.../posts").then((res) => res.json());
  return posts.map((post) => ({ slug: post.slug }));
}

If multiple layouts, pages, or generateStaticParams functions in your route tree all call fetch for the same underlying data, Next.js automatically deduplicates those requests — you don't pay for the same network round-trip multiple times just because several files independently need the same list.

Dynamic Segments in Route Handlers

The same convention and the same generateStaticParams mechanism work for dynamic API routes, not just pages:

export async function generateStaticParams() {
  const posts: { id: number }[] = await fetch(
    "https://api.vercel.app/blog",
  ).then((res) => res.json());
  return posts.map((post) => ({ id: `${post.id}` }));
}

export async function GET(
  request: Request,
  { params }: RouteContext<"/api/posts/[id]">,
) {
  const { id } = await params;
  const res = await fetch(`https://api.vercel.app/blog/${id}`);

  if (!res.ok) {
    return Response.json({ error: "Post not found" }, { status: 404 });
  }

  const post = await res.json();
  return Response.json(post);
}

IDs returned by generateStaticParams get their API responses generated once at build time; any ID that wasn't in that list still works, it's just resolved dynamically at request time instead of served from a prebuilt static file.

Key Takeaways

ConventionMatchesparams shape
[slug]Exactly one segmentstring
[...slug]One or more segmentsstring[] (requires at least one)
[[...slug]]Zero or more segmentsstring[] | undefined

Dynamic Route Segments are simple on the surface — a bracketed folder name and a params object — but the details that actually cause bugs live in two places: remembering params is a promise that must be awaited (or use()'d), and understanding that under Cache Components, generateStaticParams only validates the exact branches your samples reach. Get those two right and the rest of the convention behaves exactly as it looks like it should.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper