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 silently change how route params are resolved, making a previously reliable dynamic param appear missing, malformed, or unexpectedly bundled into another param. This happens because the router must decide which segment owns each part of the URL, and a greedy matcher changes that ownership.

If you are reproducing this with the linked sandbox, the core issue is not random: it is the interaction between nested route segment parsing and a catch-all matcher such as […slug] or [[…slug]] inside a folder tree that already contains a [param] segment.

Understanding the Root Cause

In file-system routing, each folder segment participates in URL matching from left to right. A standard dynamic segment like [category] claims exactly one path part. A catch-all segment like […slug] claims all remaining path parts. When you place a catch-all route under a dynamic segment, the router must split the URL into:

  • the value for the parent dynamic segment, and
  • the remaining values for the catch-all child segment.

The bug appears when route resolution or param serialization does not preserve that separation correctly. In practice, one of these symptoms shows up:

  • the parent params.id or similar value is missing,
  • the child catch-all absorbs segments you expected the parent to own,
  • the params object shape changes depending on which path variant is visited,
  • parallel or nested route generation produces incorrect links or page props.

Technically, the conflict comes from greedy route matching. A catch-all segment is more flexible than a single dynamic segment, so if the route tree is ambiguous, param extraction can become unstable. This is especially noticeable in frameworks using App Router-style nested segments, where params are merged across layouts, pages, and route boundaries.

For example, a structure like this can be problematic:

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

For a URL such as /acme/docs/getting-started, the expected params are often:

{
  tenant: 'acme',
  slug: ['docs', 'getting-started']
}

But once more nesting, optional segments, or conflicting sibling routes are introduced, the router may produce incorrect or inconsistent param values. The issue is not just the presence of a catch-all route. It is the combination of:

  • dynamic parent segments,
  • greedy child matching, and
  • ambiguous route boundaries.

Step-by-Step Solution

The safest fix is to remove ambiguity from the route tree. Instead of letting a catch-all live in a position where it competes with dynamic param ownership, make the route hierarchy explicit.

1. Identify the conflicting route structure

Look for a route tree similar to one of these patterns:

app/[id]/[...slug]/page.tsx
app/[locale]/[[...rest]]/page.tsx
app/[user]/settings/[...tab]/page.tsx

If the parent segment must always represent exactly one meaningful identifier, and the child is meant to capture a subpath, you should preserve that contract clearly.

2. Move the catch-all behind a static boundary

A reliable pattern is to insert a static segment before the catch-all route so the router no longer has to infer where one semantic unit ends and the other begins.

Problematic:

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

Safer:

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

Now a URL becomes:

/acme/content/docs/getting-started

And the param ownership is unambiguous:

{
  tenant: 'acme',
  slug: ['docs', 'getting-started']
}

3. Split concerns into separate routes

If the parent dynamic segment has its own page and the catch-all child represents a different content type, separate them into distinct route branches.

app/[tenant]/page.tsx
app/[tenant]/docs/[...slug]/page.tsx
app/[tenant]/settings/page.tsx

This prevents a generic catch-all route from interfering with unrelated URLs.

4. Avoid optional catch-all unless you truly need it

[[…slug]] increases ambiguity because it matches both:

  • the base path with no extra segments, and
  • any number of additional segments.

If the route should only match when extra segments exist, prefer a non-optional catch-all:

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

instead of:

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

5. Read params defensively

Even after restructuring, validate the param shape so runtime behavior is predictable.

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

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

  if (!tenant) {
    throw new Error('Missing tenant param');
  }

  return (
    <div>
      <p>Tenant: {tenant}</p>
      <p>Slug: {slug.join('/')}</p>
    </div>
  );
}

If you inserted a static boundary like content or docs, make sure all links and navigations generate URLs consistently.

import Link from 'next/link';

export function Nav() {
  return (
    <Link href="/acme/content/docs/getting-started">
      Open docs
    </Link>
  );
}

7. Test representative URLs

Verify each route variant explicitly:

/acme
/acme/content/docs
/acme/content/docs/getting-started
/acme/settings

Then log the params at the page boundary:

export default function Page({ params }) {
  console.log(params);
  return <div>...</div>;
}

If param values differ unexpectedly across sibling routes, there is still unresolved route ambiguity in the folder structure.

app/
  [tenant]/
    page.tsx
    docs/
      [...slug]/
        page.tsx
    settings/
      page.tsx

This pattern works well because:

  • the dynamic identifier stays isolated,
  • the catch-all route is scoped to one feature area, and
  • the router no longer has to guess whether a path part belongs to a parent param or child wildcard.

If you want to compare behavior against the issue reproduction, open the reproduction sandbox and apply the structural change above.

Common Edge Cases

Optional catch-all returns undefined

With [[…slug]], the value may be undefined instead of an empty array. Code that assumes params.slug.map(…) will crash.

const slug = Array.isArray(params.slug) ? params.slug : [];

Sibling routes overshadow the catch-all

If you define both a specific route and a catch-all under the same branch, route precedence matters.

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

This is valid, but your code must expect:

  • /acme/docs to hit the specific page, and
  • /acme/docs/api/auth to hit the catch-all page.

Static params generation mismatches runtime params

If using static generation helpers, ensure they return the same param shape the route expects.

export async function generateStaticParams() {
  return [
    { tenant: 'acme', slug: ['docs'] },
    { tenant: 'acme', slug: ['docs', 'getting-started'] }
  ];
}

If you accidentally return slug: ‘docs/getting-started’ as a string, generated routes can break.

Rewrites and middleware hide the real path

If middleware or rewrites transform the request URL, the route tree may appear correct while params still look wrong. Always test both the source URL and the destination route when debugging.

Layouts reading stale assumptions about params

In nested routing systems, layouts can also receive params. If a layout assumes a catch-all is always present or that a parent param always has one exact shape, rendering can break before the page runs.

FAQ

Why does a catch-all route break a parent dynamic param?

Because a catch-all segment is greedy. In a nested route tree, it can make the boundary between the parent segment and remaining path segments ambiguous, especially when optional matching or conflicting siblings are involved.

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

Yes, but the route structure should be explicit. The best practice is to put the catch-all behind a static namespace such as docs, content, or files so param ownership is deterministic.

Should I use […slug] or [[…slug]]?

Use […slug] when extra path segments are required. Use [[…slug]] only when the route must also match the base path with no child segments. If you do not need that flexibility, avoid it because it introduces more ambiguity.

The practical takeaway is simple: when a catch-all route within a dynamic segment breaks param resolution, the long-term fix is not a workaround in page code. It is a route design change that makes segment ownership explicit and removes greedy matching ambiguity from the router.

Leave a Reply

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