How to Fix: Catch all route within dynamic segment breaks dynamic params

6 min read

A catch-all route nested inside a dynamic segment can unexpectedly corrupt or shadow dynamic params, making values like params.slug or sibling route params behave inconsistently. The bug usually appears when the router has to resolve both a named segment and a variadic segment at the same depth, and the catch-all matcher wins in ways that flatten or overwrite the expected param structure.

Reproducing the problem

In the reported setup, a route tree includes both a dynamic segment such as [slug] and a catch-all segment such as [...path] beneath or alongside it. When a request matches the catch-all branch, the framework may no longer preserve the named dynamic param as originally expected.

You can inspect the reproduction from the linked CodeSandbox example. The visible symptom is that route params become incorrect, incomplete, or are exposed in a different shape than the component expects.

Understanding the Root Cause

This happens because the router builds a segment matching tree from the filesystem. A normal dynamic segment like [id] resolves to a single named token, while a catch-all segment like [...slug] resolves to an array-like token that absorbs all remaining path parts.

When a catch-all route is placed within a branch that already depends on a parent dynamic param, the matching algorithm must merge params from multiple segment types. In buggy or unsupported route shapes, one of these issues usually occurs:

  • The catch-all matcher consumes segments earlier than intended.
  • The params object is merged in the wrong order, so one param overwrites another.
  • The route ranking algorithm treats the catch-all branch as a broader match and bypasses the more specific dynamic branch.
  • The consuming page or layout assumes a string param, but the router now returns an array for the catch-all branch.

Technically, the failure is not just a rendering problem. It is a route resolution and param normalization problem. Once the filesystem route shape creates ambiguity, downstream code receives malformed or unexpected params.

The safest fix is to restructure the route tree so that dynamic segments and catch-all segments do not compete in the same ambiguous branch.

Step-by-Step Solution

The most reliable solution is to separate the route concerns and avoid placing the catch-all matcher in a position where it can interfere with the parent dynamic param.

1. Identify the conflicting route structure

A problematic structure often looks conceptually like this:

app/
  [category]/
    [...slug]/
      page.tsx

or this:

app/
  [category]/
    page.tsx
    [...slug]/
      page.tsx

In these patterns, the router must preserve category while also collecting the remaining segments into slug.

2. Move the catch-all route into a non-conflicting branch

Refactor the route tree so the catch-all segment is scoped under a static segment or a route group that makes matching explicit.

For example, instead of this:

app/
  [category]/
    [...slug]/
      page.tsx

Use this:

app/
  [category]/
    page.tsx
    path/
      [...slug]/
        page.tsx

This changes ambiguous URLs like:

/books/chapter/1

into an explicit structure like:

/books/path/chapter/1

Now the router can clearly resolve:

  • category = "books"
  • slug = ["chapter", "1"]

3. Update the page component to read params safely

type PageProps = {
  params: {
    category: string;
    slug?: string[];
  };
};

export default function Page({ params }: PageProps) {
  const category = params.category;
  const slug = params.slug ?? [];

  return (
    <div>
      <p>Category: {category}</p>
      <p>Slug: {slug.join(" / ")}</p>
    </div>
  );
}

This avoids assumptions about catch-all params always being present.

4. If optional matching is required, use optional catch-all intentionally

If the route should match both the base path and deeper nested paths, use [[...slug]] carefully:

app/
  [category]/
    path/
      [[...slug]]/
        page.tsx

Then handle the missing array case:

type PageProps = {
  params: {
    category: string;
    slug?: string[];
  };
};

export default function Page({ params }: PageProps) {
  if (!params.slug || params.slug.length === 0) {
    return <div>Base category page</div>;
  }

  return <div>Nested path: {params.slug.join("/")}</div>;
}

5. Avoid duplicate param names across nested segments

Do not define route trees that produce overlapping names like:

app/
  [slug]/
    [...slug]/
      page.tsx

This is especially fragile because the param key slug is reused for both a string and an array. Use distinct names:

app/
  [category]/
    [...segments]/
      page.tsx

6. Validate the params shape during debugging

Add temporary logging to confirm exactly what the router is returning:

export default function Page({ params }: { params: Record<string, string | string[] | undefined> }) {
  console.log(params);
  return <div>Debug route</div>;
}

If the output shape changes depending on URL depth, that is a strong signal that the current route layout is too ambiguous.

7. Prefer explicit URL design over clever nesting

If your route model requires both entity identity and arbitrary subpath traversal, the cleanest pattern is often:

app/
  items/
    [id]/
      page.tsx
      browse/
        [...path]/
          page.tsx

This keeps the resource identifier and the recursive path matcher separate.

Common Edge Cases

  • Optional catch-all returns undefined: With [[...slug]], the param may be missing entirely on the base route. Code that blindly calls join() will throw.
  • String vs array mismatch: A normal dynamic segment returns a string, but a catch-all returns an array. Shared helper functions often break if they expect one consistent type.
  • Conflicting param names: Reusing the same param name in nested dynamic and catch-all segments can overwrite values or create impossible typings.
  • Static and dynamic route collisions: A static child route such as settings may compete with a catch-all route if route precedence is unclear in your app design.
  • Layout-level param assumptions: Parent layouts may destructure params assuming only one segment exists, but deeper routes can expand the params shape.
  • generateStaticParams mismatches: If static params are generated for a dynamic route but not for the nested catch-all structure, build-time and runtime behavior may diverge.

FAQ

Why does the dynamic param disappear when I add a catch-all route?

Because the router must merge params from multiple dynamic matchers. In an ambiguous filesystem route shape, the catch-all segment can consume path parts or override the final params object in a way that makes the original dynamic param unavailable or malformed.

Can I keep both a parent dynamic segment and a child catch-all route?

Yes, but only when the URL structure is explicit enough to avoid ambiguity. The safest approach is to place the catch-all route under a static child segment such as path, browse, or docs.

Should I use optional catch-all to fix this bug?

Not by itself. [[...slug]] only changes whether the segment is optional. It does not solve route ambiguity. Use it only after restructuring the route tree so the router can resolve params deterministically.

The practical fix is simple: do not let a catch-all route compete directly with a dynamic segment in the same ambiguous branch. Introduce a static boundary, use distinct param names, and handle string-versus-array params deliberately. That preserves stable routing behavior and prevents dynamic params from breaking as the app grows.

Leave a Reply

Your email address will not be published. Required fields are marked *