Handling Catch-All Routes Without Duplicate URLs

A catch-all segment is the most convenient way to map CMS paths onto routes and the easiest way to accidentally publish the same article at an unlimited number of URLs.

When to Use This Approach

Audit your catch-all when:

  • Search Console shows far more discovered URLs than your CMS has entries.
  • Crawler logs contain paths with segments that appear nowhere in your content model.
  • The route file is named with a spread segment and resolves content by taking the last element.

How the Surface Opens Up

The bug is a single line: resolving segments.at(-1) instead of the whole array. Its consequence is unbounded.

Last-segment resolution against full-path resolution Three incoming paths that share a final segment: last-segment resolution returns the same page for all three, while full-path resolution returns 200 for one and 404 for the others. requested path last segment only full path /blog/my-post /blog/anything/my-post /x/y/z/my-post 200 — the same article unbounded duplicate URLs 200 — canonical 404 404 The path is an identifier, not a container for one — validate all of it

Implementation Steps

Step 1: Probe what the route currently accepts

for path in \
  /blog/my-post \
  /blog/anything/my-post \
  /x/y/z/my-post \
  /blog/my-post/extra \
  /BLOG/My-Post ; do
  printf '%-32s %s\n' "$path" \
    "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$path")"
done

Validation: Only the first should return 200. Any other 200 is a duplicate URL that is already discoverable.

Step 2: Resolve the whole path against the CMS

Store the full path on the entry so resolution is a lookup rather than a reconstruction.

// app/[...segments]/page.jsx
import { notFound } from 'next/navigation';

export default async function CatchAll({ params }) {
  const path = '/' + params.segments.join('/');
  const entry = await cms.byPath(path);            // exact match on a stored path
  if (!entry) notFound();
  return <Renderer entry={entry} />;
}
// lib/cms.js
export async function byPath(path) {
  const normalised = path.toLowerCase().replace(/\/+$/, '') || '/';
  return store.get(`path:${normalised}`) ?? null;   // no fuzzy fallback
}

SEO impact: Only paths that exist in the content model resolve, so the crawlable surface equals the published surface exactly.

Validation: Re-run Step 1 and confirm every invented path now returns 404.

Step 3: Make not-found a real 404

// app/not-found.jsx — Next.js returns a genuine 404 status for this
export default function NotFound() {
  return (
    <main>
      <h1>Page not found</h1>
      <p>The page you requested does not exist. Try the <a href="/">home page</a>.</p>
    </main>
  );
}

SEO impact: A 200 rendering a “not found” message is a soft 404 — a crawlable URL that keeps consuming budget and may be indexed. The status code is the signal, not the wording on the page.

Validation: curl -s -o /dev/null -w '%{http_code}\n' https://example.com/does-not-exist returns 404. The full diagnosis is in fixing 404s in headless dynamic routes.

Step 4: Redirect historical paths instead of serving them

Some non-canonical paths are real history and deserve a redirect rather than a 404.

// lib/cms.js — resolution returns either an entry or a redirect instruction
export async function resolve(path) {
  const normalised = normalisePath(path);
  const entry = await store.get(`path:${normalised}`);
  if (entry) return { kind: 'entry', entry };

  const historical = await store.get(`alias:${normalised}`);
  if (historical) return { kind: 'redirect', to: historical, status: 301 };

  return { kind: 'not-found' };
}
// app/[...segments]/page.jsx
import { redirect, notFound } from 'next/navigation';

export default async function CatchAll({ params }) {
  const result = await cms.resolve('/' + params.segments.join('/'));
  if (result.kind === 'redirect') redirect(result.to);
  if (result.kind === 'not-found') notFound();
  return <Renderer entry={result.entry} />;
}

SEO impact: Aliases from slug changes keep their equity through a single 301, while invented paths get a 404 — the two outcomes a catch-all needs to distinguish and usually does not.

Validation: An alias returns 301 to the current path in one hop; a fabricated path returns 404.

Step 5: Normalise case and trailing slashes once

// lib/normalise-path.js — one enforced canonical form
export function normalisePath(path) {
  const lower = path.toLowerCase();
  const trimmed = lower.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
  return trimmed || '/';
}

// middleware.js — redirect rather than accept
export function middleware(request) {
  const url = new URL(request.url);
  const canonical = normalisePath(url.pathname) + '/';
  if (url.pathname !== canonical) {
    url.pathname = canonical;
    return NextResponse.redirect(url, 301);
  }
  return NextResponse.next();
}

SEO impact: Case and slash variants become redirects instead of duplicate 200 responses, which is the same discipline described in implementing SEO-friendly slug normalization.

Validation: /BLOG/My-Post returns 301 to /blog/my-post/ in one hop.

Step 6: Lock the behaviour in tests

// tests/catch-all.test.js
const cases = [
  ['/blog/my-post/', 200],
  ['/blog/anything/my-post/', 404],
  ['/x/y/z/my-post/', 404],
  ['/blog/my-post/extra/', 404],
  ['/BLOG/My-Post/', 301],
  ['/blog//my-post/', 301],
  ['/blog/old-slug/', 301],
];

test.each(cases)('%s -> %i', async (path, expected) => {
  const res = await fetch(BASE + path, { redirect: 'manual' });
  expect(res.status).toBe(expected);
});

Validation: These assertions must run against a deployed environment, not a mocked router, because the middleware and the route interact.

Resolution Order

The order of the checks determines the outcome, and getting it wrong produces subtle failures such as an alias returning 404 or a canonical path redirecting to itself.

The order in which a catch-all must resolve a path Four ordered checks — normalise and redirect if changed, exact path match, alias lookup with a 301, and finally a 404 — with the outcome of each. 1. normalise case, slashes 2. exact match full stored path 3. alias lookup historical paths 4. not found real 404 status 301 if it changed 200 render 301 to current path Each step either terminates or hands on — never both Normalising after the lookup causes canonical paths to redirect to themselves

SEO Impact Summary

The effect on the crawlable surface is the clearest way to see the change: an exact-match resolver converts an unbounded space into a finite one.

Crawlable surface before and after exact resolution A wide bar representing an unbounded crawlable surface under prefix-insensitive resolution, and a narrow bar equal to the published entry count after exact matching. before unbounded — every prefix resolves after exactly the published entries Nothing a visitor could reach was removed — only paths nobody published
Signal What improves What breaks if misconfigured
Index composition The crawlable surface equals the published surface Prefix-insensitive resolution publishes every article at unlimited URLs
Crawl budget Requests stop being spent on fabricated paths Soft 404s keep returning 200 and keep being fetched
Canonical clarity One URL per entry, with history handled by redirects Serving both an alias and the current path splits the signals
Migration continuity Old paths keep their equity through one 301 Treating aliases as not-found discards accumulated links

Measurable signals to watch:

  • Discovered URL count versus CMS entry count, which should converge.
  • Count of 200 responses for paths with segments absent from the content model, which should be zero.
  • Soft 404 reports in Search Console, which should disappear once the status code is real.

Edge Cases and Gotchas

Normalisation must happen before the lookup If the exact-match check runs on the raw path and normalisation happens afterwards, a correctly formed URL can redirect to itself and loop. Order the steps as in the diagram.

Aliases must not accumulate into chains When a slug changes twice, the oldest alias should point at the current path directly, not at the intermediate one. Rewrite aliases on each change rather than appending — see flattening redirect chains in edge middleware.

A catch-all can shadow a static route Framework routers usually prefer the more specific route, but a middleware rewrite can defeat that. Test your static routes explicitly after any change to the catch-all.

Percent-encoding creates equivalent paths that do not string-match /blog/caf%C3%A9 and /blog/café are the same path but different strings. Decode before normalising, and store the decoded form. This is the same class of problem as multi-locale slug transliteration.

Frequently Asked Questions

Why does a catch-all route create duplicates at all? Because a naive implementation resolves only the final segment and ignores the rest of the path. Every prefix that reaches the route then serves identical content, and a crawler that finds one variant will explore more.

Is a soft 404 really that damaging? At catch-all scale, yes. Each one is a crawlable URL returning 200, so Google keeps fetching it and may index it. Google detects some soft 404s heuristically, but a real 404 status is unambiguous and immediate.

Should the catch-all handle trailing slashes and case? It should normalise them by redirecting, not by accepting both. Serving /Blog/My-Post and /blog/my-post as 200 creates two URLs for one page. Pick a canonical form and enforce it in one place.

What about deliberately nested content paths? Those are fine — the rule is not that paths must be shallow but that they must be exact. If /guides/seo/canonical-urls is a stored path, it resolves; if /guides/anything/canonical-urls is not, it 404s. Depth is irrelevant; existence is what matters.


Part of: Dynamic Route Generation

Related