The Problem
I ran into this on a SaaS dashboard last week. The app uses parallel routes: a main @feed slot and a @sidebar slot rendered side by side under app/dashboard/layout.tsx. On a hard refresh of /dashboard/settings, everything renders. But when the user soft-navigates from /dashboard to /dashboard/settings via a Link, the @sidebar slot suddenly throws a 404 boundary and the whole layout collapses.
If you upgraded to Next.js 16 and your parallel route slots started 404-ing after a client-side navigation that worked fine in 15, this is the new strict matching behaviour and a missing default.tsx. Took me an hour to track down the first time, so here is the short version.
Why It Happens
Parallel routes in the App Router work by rendering multiple route trees inside a single layout at the same time. Each named slot — @feed, @sidebar, @modal — has its own folder and its own routing tree.
The catch: when the user navigates to a URL that matches one slot but not another, Next.js needs to know what to render in the unmatched slot. In 15, the framework would silently fall back to the previously rendered content of that slot during a soft navigation. In 16 it does not. Each slot must explicitly declare a default.tsx for the segments it does not match, otherwise Next.js renders the closest 404 boundary for the whole subtree.
This is documented but easy to miss. The 16 release notes call it "strict slot resolution." It only fires on client-side navigations because hard refreshes hydrate from the URL and use whatever page.tsx matches at that exact path. Soft navigations re-use the layout and need the unmatched-slot fallback, which default.tsx provides.
There is a second gotcha. If the parallel slot folder is missing the catch-all default at the root of the slot, the 404 fires even when the slot has nested matching routes. The default.tsx must live at the same level as the slot's first page.tsx, not deeper.
A third one I hit on a multi-tenant build: parallel routes inside a route group (group)/@slot need their own default.tsx at the route-group level too, because the group is a separate routing boundary in 16.
The Fix
Step 1: Add default.tsx to every parallel slot.
Folder structure that works in 16:
app/
└── dashboard/
├── layout.tsx
├── page.tsx
├── @feed/
│ ├── page.tsx
│ ├── default.tsx ← required in 16
│ └── settings/
│ └── page.tsx
└── @sidebar/
├── page.tsx
├── default.tsx ← required in 16
└── settings/
└── page.tsx
Each default.tsx should return whatever you want the slot to render when its routing tree does not match the current URL. The simplest version is:
// app/dashboard/@sidebar/default.tsx
export default function Default() {
return null
}
Returning null is fine if you want the slot to disappear for unmatched URLs. If you want the default state to persist, return your default component instead.
Step 2: Wire the slot into the layout correctly.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
feed,
sidebar,
}: {
children: React.ReactNode
feed: React.ReactNode
sidebar: React.ReactNode
}) {
return (
<div className="grid grid-cols-[1fr_320px] gap-6">
<main>
{children}
{feed}
</main>
<aside>{sidebar}</aside>
</div>
)
}
Slot prop names must match the folder names without the @ prefix. @feed becomes the feed prop. If your layout typed the props as optional with feed?: React.ReactNode, TypeScript silently passes undefined when the slot is missing and you never see the actual error. Make them required.
Step 3: If you use route groups, add a default at the group level too.
app/
└── (authed)/
├── layout.tsx
├── @sidebar/
│ ├── default.tsx ← required at group level
│ └── dashboard/
│ ├── default.tsx ← required at nested level
│ └── page.tsx
└── dashboard/
└── page.tsx
The 16 strict resolver treats (authed) as its own boundary, so the slot must declare its default at every nesting level you have layouts at.
Step 4: Verify the slot is rendering with a temporary log.
// app/dashboard/@sidebar/default.tsx
export default function Default() {
if (process.env.NODE_ENV === 'development') {
console.log('[sidebar default] rendered for unmatched route')
}
return null
}
Then click through your Link components and watch the browser console. If the log fires and the slot renders, you are done. If the log fires but the layout still shows a 404, the issue is that notFound() is being called inside one of your slot's page.tsx files and bubbling up. Wrap the call site in a try or move the notFound() into a leaf route.
Step 5: If you also use intercepted routes ((.), (..)), add default.tsx for the intercepted version too. This is the modal-pattern bug. An intercepted route like @modal/(.)photo/[id]/page.tsx needs @modal/default.tsx at the slot root, otherwise the modal slot 404s on every page that does not have a photo open.
The Next.js parallel routes docs have the full conventions reference with a worked example for the auth/dashboard pattern.
The Lesson
The 15-to-16 parallel routes break is a quiet contract change. Add default.tsx to every slot at every routing boundary, type the layout slot props as required, and verify with a console log inside default.tsx that the unmatched-slot fallback is firing. Once you have that pattern wired up, soft navigations stop throwing the misleading 404 and the layout holds together.
If you are mid-migration to Next.js 16 and seeing parallel routes, intercepted routes, or proxy.ts issues at the same time, that is the bulk of what I do right now — see my services. And if your proxy.ts also stopped firing after the upgrade, I wrote the Next.js 16 proxy.ts not running fix which usually pairs with this one.
