Dynamic Routes in Next.js 16 — Wrong [slug] Matched 404 for 40% of Product Pages
Catch-all routes matching too greedily or not at all: [slug], [...slug], route groups, and 40% product page 404s in production.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Next.js 14+ with App Router
- ✓Understanding of file-system routing basics
- ✓Node.js 18+
- Dynamic routes ([param], [...slug], [[...slug]]) have a predictable priority order — static first, dynamic last
- The wrong [slug] matches when two dynamic segments are siblings — the file-system router picks the first match, not the most specific
- Route groups ((group)) prevent layout nesting but can break route matching when combined with dynamic parameters
- A catch-all [...slug] at /products/[...slug] catches EVERYTHING including /products — but only if you handle the root case
- 40% of product pages returned 404 because /products/[category]/[product] was shadowed by /products/[...slug] with no empty array fallback
Think of Next.js dynamic routes like a filing cabinet. You label folders [customer] and [...customer]. When someone asks for the file 'John', the router opens whichever folder it finds first — which might be the catch-all bin instead of the specific folder. The fix is organizing folders so the specific one is checked before the catch-all.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Next.js file-system routing is elegant until it is not. The problem surfaces when you have multiple dynamic segments at the same level — /products/[category]/[product] and /products/[...slug] living side by side. The router picks one, and it is rarely the one you expect.
This article breaks down how Next.js resolves dynamic route conflicts, why route groups ((dashboard)) interact poorly with dynamic segments, and how to diagnose the 'wrong route matched' bug that caused 40% of product pages to 404 in a recent production incident.
Tested on Next.js 16.0.0-canary with App Router. The resolution rules are identical in Next.js 14 and 15 — this is a design invariant, not a versioning bug.
The File-System Router Is Not Smart — It Is Deterministic
Next.js App Router resolves routes by file-system traversal. It does not evaluate which route is more specific — it picks the first hit in a deterministic priority list:
- Static segments (exact literal paths like /products/featured)
- Dynamic single segments ([param] like [category])
- Catch-all segments ([...param] like [...slug])
- Optional catch-all ([[...param]] like [[...slug]])
This priority is documented but easy to miss when adding a new route. The order within each tier is alphabetical by folder name. So app/products/[category] and app/products/[...slug] are both tier 2 dynamic segments. If the file system sorts [...slug] before [category] (it usually does because '[' sorts before brackets with content), Next.js resolves [...slug] first.
The '[id]' pattern sorts before '[...slug]' lexicographically in most platforms — but '[category]' and '[...slug]' have no guaranteed priority. The result is unpredictable behavior depending on your OS and how the file system orders bracket-prefixed directory names.
This is why the fix is never 'reorder your folders' — it is always 'add a literal prefix segment'.
Route Groups ((folder)): They Drop from URL but Not from Route Resolution
Route groups are parentheses-folders that organize layouts without affecting the URL path. app/(marketing)/about/page.tsx renders at /about. The parenthesis content is stripped from the URL.
The trap: route groups create a nesting level in the route tree but not in the URL. If you have app/(marketing)/products/[slug] and app/products/[...catchall], those two routes are siblings in URL space — but the file system places them in different branches. Next.js resolves routes by file-system path first, then maps to URL. The result is that (marketing)/products/[slug] may shadow app/products/[...catchall] or vice versa, depending on the sort order of the two directory structures.
Route groups should not be used to organize routes that share URL paths. They are for layout grouping only. If two routes need different layouts but share URL patterns, use parallel routes instead. If they need no layout relationship, put them in completely separate directory trees.
Catch-All [...slug] vs Optional Catch-All [[...slug]]: The Empty Array Edge Case
The difference between [...slug] and [[...slug]] is precisely one edge case: the root path. app/products/[...slug]/page.tsx matches /products/a and /products/a/b but NOT /products. app/products/[[...slug]]/page.tsx matches all three: /products, /products/a, and /products/a/b.
The catch-all variant passes an array: params.slug = ['a'] for /products/a. Optional catch-all passes an empty array for /products: params.slug = []. Handle this case or your root product listing page 404s.
In the production incident described earlier, the team used [...slug] instead of [[...slug]], so /products (the root listing) continued working via the [category]/[product] route. But /products/a/b (three segments) was caught by [...slug] before [category]/[product] could handle it. The fix with [[...slug]] would not have helped — the root case was fine. The issue was priority, not parameters.
Choose catch-all patterns deliberately: use [...slug] when the root path has its own handler, use [[...slug]] when the catch-all should also handle the root case.
Route Resolution Order: The Full Priority Table with Examples
Next.js resolves routes in this exact priority order when multiple routes match the same URL:
- Static routes (exact match) — /products/featured
- Single dynamic segments — /products/[category]
- Catch-all segments — /products/[...slug]
- Optional catch-all — /products/[[...slug]]
- Root handler — /products/page.tsx
Within each tier, lexical order of the folder name determines priority. 'a' sorts before 'b', '[a]' sorts before '[b]'. But here is the problem: at the file system level, '[...slug]' and '[category]' are both dynamic — they belong to the same tier. The tier list above is misleading because it groups all dynamic patterns together.
- Tiers 1-2: literal segments and static pages
- Tier 3: ALL dynamic segments ([param], [...slug], [[...slug]]) — resolved alphabetically among themselves
- Tier 4: notFound() and default.js
This means [category] and [...slug] compete at the same priority level. The winner is determined by which folder name sorts first in your specific file system — ext4 on Linux sorts differently than APFS on macOS in some edge cases.
Never rely on sort order. Always test the production build output with 'next build --debug' and verify route resolution for every dynamic pattern.
Parallel Routes and Intercepting Routes: The @slot Shadow War
Parallel routes (@slot) and intercepting routes ((.)pattern) introduce modal-style navigation patterns but also introduce a unique class of route conflicts. An @slot renders a separate route tree alongside the main page — it does not replace the main route. But the slot's default.js file can shadow the slot's page.tsx in ways that are hard to debug.
When a parallel route slot has no matching page for the current URL, Next.js falls back to default.js. If default.js exists at the parent level (outside the slot directory), it is used as a fallback. But if the slot directory has no page.tsx and no default.js, the slot renders nothing — which is often interpreted as a bug.
The intercepting route pattern (.) matches a sibling route level. (.)products at app/feed/(.)products/page.tsx intercepts /products when navigated from /feed. The problem: if /products is also a catch-all like /[...slug], the intercepting route tries to match the same path. The priority between intercepting routes and regular routes depends on the navigation source — client-side navigation follows intercepting patterns, direct URL access skips them.
This dual behavior (intercept on client, skip on direct) means your page renders differently depending on navigation source. Test both paths.
Grouping Without Groups: Using Parallel Layouts for Multi-Level Navigation
When route groups ((folder)) feel like a hack for sharing layouts across non-adjacent routes, parallel routes offer a cleaner alternative. Instead of forcing every page under one layout, use parallel routes to compose multiple layout sections that each navigate independently.
Pattern: app/@sidebar renders a navigation sidebar. app/@main renders the content area. Both are parallel route slots fed into the same layout. The sidebar slot can navigate independently (showing different sections) while the main slot shows the primary content.
This works because each @slot renders its own matched page from the URL — the sidebar slot matches /@sidebar/... while the main slot matches /@main/... or the default route. The layout orchestrates both.
The cost: more files, more default.js files, and more cognitive overhead for new team members. Parallel routes solve layout problems but add complexity. Do not use them unless you genuinely need independent slot navigation.
Use the Build Output to Debug Routes — Not the Browser
The browser devtools show the rendered page, not the route that produced it. Use 'next build' output to verify which page.tsx matches each URL pattern. The build output lists every route and the file that handles it.
Run 'npx next build --debug' and pipe through grep for route output. The build prints something like: - /products → app/products/page.tsx (static) - /products/[category] → app/products/[category]/page.tsx (dynamic) - /products/[...slug] → app/products/[...slug]/page.tsx (catch-all)
If you see a route mapped to the wrong file, you have a conflict. The build does not warn about overlapping routes — it silently picks one. Only manual inspection reveals the conflict.
Add a CI step that compares expected route patterns against build output. Any discrepancy between your expectations and the resolved routes should fail the build.
Catch-All Products Shadowed Category Pages — 40% of Catalog Returned 404
- Next.js route priority is file-system order, not specificity — catch-all routes often win over explicit nested dynamic segments
- Never add a catch-all [...slug] at the same level as other dynamic routes without a literal prefix to disambiguate
- Add automated route conflict detection to CI — a simple script that checks for overlapping route patterns
- 40% 404 rate is not a user error — it is a router misconfiguration that should never reach production
find app -name 'page.tsx' | sortnpx next build --debug 2>&1 | grep 'Route'| File | Command / Code | Purpose |
|---|---|---|
| app | export default async function ProductsPage({ | Catch-All [...slug] vs Optional Catch-All [[...slug]] |
| debug-route-priority.sh | npx next build --debug 2>&1 | grep -E "Route|✓" | Route Resolution Order |
| app | export default function ModalDefault() { | Parallel Routes and Intercepting Routes |
| app | export default function RootLayout({ | Grouping Without Groups |
| ci-route-check.sh | ROUTES=$(npx next build --debug 2>&1 | grep -E '^[─├└]' | sed 's/.*\///' | sed '... | Use the Build Output to Debug Routes |
Key takeaways
Interview Questions on This Topic
What is the route resolution priority in Next.js App Router?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Next.js. Mark it forged?
4 min read · try the examples if you haven't