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.
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.
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.
| 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
200responses 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
- Fixing 404s in Headless Dynamic Routes — getting the status code right once resolution is correct
- Automating Dynamic Route Generation for Headless Blogs — building the path index this resolution depends on
- Implementing SEO-Friendly Slug Normalization — the canonical path form the normaliser enforces