## Summary
Parallel route layouts can be composed entirely from named slots, but
loader tree construction currently synthesizes a `children` fallback
whenever any named slot exists. This makes `children` semantically
required even when no page, default, or ordinary route branch declares
it.
This adds `experimental.explicitParallelRouteChildren` and enables it by
default. When enabled, `children` is included in a layout slot set only
when the filesystem declares an ordinary route that can render at that
level. A layout by itself is only structure and does not declare a route
target. Ordinary descendants are traced until they reach a page or
default, including through deeper named slots, before they cause
`children` to be included. Setting the flag to `false` temporarily
restores the legacy implicit `children` fallback.
This flag only controls whether `children` exists in the loader tree. It
does not prune incomplete matchers; that is the separate
`experimental.strictRouteMatching` behavior in the next PR.
Named slots keep their existing default and soft navigation semantics.
The preceding PR retains the slots owned by an interception host without
treating `children` specially, so an undeclared child is no longer
needed for that behavior. A real `children` branch still uses the
retention marker when it is one of the host layout slots.
## Semantics
For example, this layout declares only named slots:
```text
app/dashboard/layout.tsx
app/dashboard/@left/page.tsx
app/dashboard/@right/page.tsx
```
With `explicitParallelRouteChildren` disabled, Next.js adds a synthetic
`children` branch whose built-in default calls `notFound()`, even though
the layout never declared or rendered it. With the default behavior
enabled, the loader tree contains only `left` and `right`, so
`/dashboard` is matched from the route targets that actually exist.
This is different from an ordinary branch whose route targets are deeper
in the tree:
```text
app/nested/layout.tsx
app/nested/@sidebar/[...slug]/page.tsx
app/nested/content/layout.tsx
app/nested/content/@left/[...slug]/page.tsx
app/nested/content/@right/[...slug]/page.tsx
```
Here `content` really is the `children` branch of `nested`. The scan
follows `content` through its layout and deeper named slots, so
`children` remains required. `/nested/content/anything` can construct
every declared slot, while `/nested/incomplete` only matches `sidebar`
and is still incomplete. The distinction is whether the ordinary
descendant eventually reaches a page or default, not whether a layout
happens to exist along the way.
The focused children detection coverage proves that a layout-only
descendant does not synthesize `children`, while an ordinary branch
whose route targets live inside deeper named slots still does. The
limitation coverage also proves that named-only trees render pages, CSS,
metadata, and regular error boundaries. It intentionally asserts the
current broken behavior for HTTP access fallbacks and metadata or
viewport failures so those expectations can be flipped when renderer
ownership no longer depends on `children`.
## Verification
- `pnpm build-all`
- Turbopack and webpack development and production coverage for
`explicit-parallel-route-children-detection`
- Turbopack and webpack production coverage for
`interception-dynamic-segment`, `parallel-routes-layouts`, and
`explicit-parallel-route-children-legacy`
- The same existing production coverage with Cache Components enabled
- Turbopack and webpack production coverage for the documented
named-only limitations
<!-- NEXT_JS_LLM -->
## Summary
Interception routes represent a partial update to the layout that hosts
them. Today we model this for `children` by synthesizing a `__DEFAULT__`
route backed by `default-null`, but named siblings still use normal
default matching even though they should retain their active state too.
This makes retention relative to the interception host instead of the
`children` key. Every non-intercepting sibling gets the existing
`__DEFAULT__` marker backed by `default-null`, while slots inside the
newly selected interception subtree continue to use normal matching and
real defaults.
The coverage uses a named-only host to verify that named siblings retain
client state without evaluating a user default, that a sibling without a
default also retains, and that a hard refresh still loads the canonical
route.
## Example
Consider this route tree:
```text
app/
├── named-host/
│ ├── layout.tsx # renders canonical, content, secondary, and modal
│ ├── @canonical/
│ │ └── page.tsx
│ ├── @content/
│ │ ├── page.tsx # renders a stateful counter
│ │ └── default.tsx # throws if evaluated
│ ├── @secondary/
│ │ └── page.tsx # has no default.tsx
│ └── @modal/
│ ├── default.tsx
│ └── (..)named-target/
│ └── page.tsx # intercepted target
└── named-target/
└── page.tsx # canonical route
```
Before this change, a soft navigation from `/named-host` to
`/named-target` could replace `@content` with its throwing default and
treat `@secondary` as missing because only `children` had retain
semantics. After this change, only `@modal` switches to the intercepted
page; `@content` and `@secondary` keep their existing UI and the counter
keeps its state. A hard refresh still renders the canonical
`/named-target` route instead of the interception host.
## Verification
- Turbopack and webpack production coverage in
`interception-dynamic-segment`
- The same production coverage with Cache Components enabled
<!-- NEXT_JS_LLM -->
## What
Fixes a bug where interception routes in parallel slots could not be
prerendered using `generateStaticParams`, causing 404 responses when
these routes were accessed directly.
## Why
**The Problem:**
Interception routes like `app/@modal/(.)photo/[id]/page.tsx` could not
be prerendered even when they exported `generateStaticParams`. This was
because the static path generation code only examined "children"
segments in the loader tree, completely missing segments from parallel
routes (like `@modal`) that actually contribute to the pathname.
**Root Cause:**
The previous implementation used `childrenRouteParamSegments` which only
traversed the `children` branch of the loader tree:
```typescript
// OLD: Only looked at children
const childrenRouteParamSegments = [...segments from children only...]
// This missed parallel routes like @modal that have dynamic segments
```
For a route structure like:
```
app/
[username]/
page.tsx
@modal/
(.)[username]/
[id]/
page.tsx // ← This route's segments were MISSED
```
The build system couldn't discover the `[id]` parameter in the parallel
route because it never traversed that branch of the tree.
## How
**Solution:**
Introduces `extractPathnameSegments()` which properly traverses the
ENTIRE loader tree (not just children) to find ALL segments that
contribute to the pathname:
1. **BFS Traversal**: Explores both `children` AND all parallel route
slots (e.g., `@modal`, `@sidebar`)
2. **Depth Tracking**: Correctly tracks URL depth by:
- Skipping route groups `(marketing)` - not in URL
- Skipping parallel markers `@modal` - not in URL
- Including interception markers `(.)photo` - ARE in URL
3. **Prefix Validation**: Ensures static segments match the target
pathname before including dynamic segments
4. **Complete Parameter Discovery**: Returns all segments that
contribute to pathname construction, regardless of which tree branch
they're in
**Example:**
For `app/@modal/(.)photo/[id]/page.tsx`:
- Old: Missed the `[id]` parameter entirely
- New: Discovers `[id]` and enables prerendering with
`generateStaticParams`
## Changes
**New Module**: `extract-pathname-segments.ts` (192 lines)
- Core algorithm for traversing loader tree and extracting pathname
segments
- Handles complex cases: parallel routes, interception routes, route
groups
- Well-documented with examples and algorithm explanation
**Comprehensive Tests**: `extract-pathname-segments.test.ts` (897 lines)
- Tests for simple cases, nested structures, parallel routes
- Interception route handling in various configurations
- Route group behavior and edge cases
- Depth tracking validation
**Integration**: `static-paths/app.ts`
- Replaced `childrenRouteParamSegments` with `extractPathnameSegments()`
- Updated pathname construction to use segments from parallel routes
- Maintained backward compatibility
**E2E Test**: Added test validating prerendering works for intercepted
routes
## Test Plan
The new E2E test verifies:
```typescript
it('should prerender a dynamic intercepted route', async () => {
// Verifies build output contains the prerendered interception route
expect(next.cliOutput).toContain('/(.)john/1')
// Verifies it doesn't generate the non-intercepted path
expect(next.cliOutput).not.toContain('/john/1')
})
```
## Impact
**Before**: Interception routes with dynamic segments returned 404 when
accessed directly, even with `generateStaticParams`
**After**: These routes are properly prerendered at build time and
return correct responses
### What?
Introduces a null-rendering default component for interception routes to
prevent 404 responses when children slots are missing.
### Why?
When rendering interception routes like `(.)[id]`, there's a segment
mismatch where the interception route lacks a children slot that the
base `[id]` route has.
**The Bug:**
Previously, the framework injected a 404 page into the missing default
slot for the children route. On platforms like Vercel, this caused full
404 RSC responses because the HTTP status code has semantic meaning for
route behavior when using cache components. This resulted in the
"unknown segment value fallback page" issue.
**Impact:**
- RSC requests for interception routes would return 404 status codes
- Platform routing behavior was incorrectly triggered by these 404
responses
- Only affects pages rendering with cache components
### How?
- Created new `default-null.tsx` builtin component that renders `null`
- Updated Rust code (`app_structure.rs`) to detect interception routes
and use the null default
- Updated webpack loader (`next-app-loader/index.ts`) to check
`isInterceptionRouteAppPath()` and inject the null default
- Removed test-specific default component that was working around this
bug
- The old `page.tsx` is still used during client navigation, so there's
no visible change to users
This ensures minimal RSC payload changes and prevents 404 status codes
while maintaining existing client-side behavior.
NAR-483
This case is fixed on `canary`, but has a regression on 15.1. This PR
adds a test case that succeeds on `canary` but fails on the backport
branch. A fix for the backport branch is forthcoming.
### What?
Using an interception marker next to a dynamic segment does not behave
properly when deployed to Vercel
### Why?
The named route regex that gets created is not accounting for the
interception marker, which is causing the non-intercepted route to match
the intercepted serverless function.
### How?
This factors in the interception marker when building the named route
regex so that the non-intercepted route regex properly matches when
loading the non-intercepted page.
Deployment verified here: https://test-intercept-mu.vercel.app/
Closes NEXT-1786
Fixes#54650