Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
out/
*.tsbuildinfo
.vite/
.vinext/
.turbo/
.ecosystem-test/
.next/
Expand Down
6 changes: 6 additions & 0 deletions examples/app-router-playground/app/_internal/_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ const demos = [
name: 'Parallel Routes',
description: 'Render multiple pages in the same layout',
},
{
slug: 'intercepting-routes',
name: 'Intercepting Routes',
description:
'Open a detail route as a modal during navigation while preserving the shareable URL',
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import db from '#/lib/db';
import { Boundary } from '#/ui/boundary';
import { XMarkIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';

export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = db.product.find({ where: { id } });
if (!product) {
notFound();
}

return (
<div className="pointer-events-none fixed inset-0 z-20 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<Boundary
label="@modal/(.)photo/[id]/page.tsx"
color="cyan"
kind="solid"
animateRerendering={false}
className="pointer-events-auto flex w-full max-w-3xl flex-col gap-6 rounded-2xl bg-gray-950"
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-2">
<div className="text-sm uppercase tracking-[0.2em] text-cyan-300">
Intercepted in modal
</div>
<h2 className="text-2xl font-semibold text-white">{product.name}</h2>
</div>

<Link
href="/intercepting-routes"
className="rounded-full border border-gray-800 p-2 text-gray-400 hover:border-gray-700 hover:text-white"
aria-label="Close modal"
>
<XMarkIcon className="size-5" />
</Link>
</div>

<div className="grid gap-6 lg:grid-cols-[minmax(0,16rem)_1fr]">
<div className="overflow-hidden rounded-2xl bg-gray-900/60 p-6">
<Image
src={`/shop/${product.image}`}
alt={product.name}
width={320}
height={320}
className="mx-auto brightness-150"
/>
</div>

<div className="flex flex-col gap-4">
<p className="text-sm leading-6 text-gray-400">
The browser URL is already pointing at the product detail page, but
the source gallery stays mounted underneath because the navigation
was intercepted by the parallel slot.
</p>
<div className="font-mono text-sm text-cyan-300">
${product.price.toFixed(2)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This renders a literal $ followed by the price. In JSX, $ is not interpolated, so this renders correctly as $1.99 etc. — but it reads confusingly like a template literal that's missing backticks. Consider wrapping in braces for clarity:

Suggested change
${product.price.toFixed(2)}
{`$${product.price.toFixed(2)}`}

</div>
<div className="text-xs text-gray-500">
Refresh this URL to see the standalone detail page instead.
</div>
</div>
</div>
</Boundary>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return null;
}
46 changes: 46 additions & 0 deletions examples/app-router-playground/app/intercepting-routes/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use cache';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 'use cache' intentional here? This layout receives modal as a prop (a React node from a parallel slot). Caching a layout that renders dynamic slot content could produce stale modal content or cache key explosions depending on how the cache boundary interacts with RSC streaming.

Other layouts in the playground (e.g. parallel-routes/layout.tsx) don't use 'use cache'. If this was copied from another layout template, consider removing it.


import db from '#/lib/db';
import { Mdx } from '#/ui/codehike';
import { Boundary } from '#/ui/boundary';
import { type Metadata } from 'next';
import React from 'react';
import readme from './readme.mdx';

export async function generateMetadata(): Promise<Metadata> {
const demo = db.demo.find({ where: { slug: 'intercepting-routes' } });

return {
title: demo.name,
openGraph: { title: demo.name, images: [`/api/og?title=${demo.name}`] },
};
}

export default function Layout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
<Boundary label="Demo" kind="solid" animateRerendering={false}>
<Mdx source={readme} collapsed={true} />
</Boundary>

<div className="relative flex flex-col gap-6">
<Boundary
label="layout.tsx"
kind="solid"
animateRerendering={false}
className="flex flex-col gap-6"
>
{children}
</Boundary>

{modal}
</div>
</>
);
}
33 changes: 33 additions & 0 deletions examples/app-router-playground/app/intercepting-routes/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import db from '#/lib/db';
import { Boundary } from '#/ui/boundary';
import { ProductCard, ProductList } from '#/ui/product-card';
import Link from 'next/link';

export default function Page() {
const products = db.product.findMany({ limit: 6 });

return (
<Boundary label="page.tsx" size="small" className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold text-gray-200">
Product gallery with modal interception
</h1>
<p className="max-w-2xl text-sm text-gray-400">
This route stays visible while the target URL updates to a nested detail
page. A direct load of the same URL renders the standalone detail page.
</p>
</div>

<ProductList title="Products" count={products.length}>
{products.map((product) => (
<Link key={product.id} href={`/intercepting-routes/photo/${product.id}`}>
<ProductCard
product={product}
className="rounded-xl border border-transparent p-2 transition hover:border-gray-800"
/>
</Link>
))}
</ProductList>
</Boundary>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import db from '#/lib/db';
import { Boundary } from '#/ui/boundary';
import { ChevronLeftIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';

export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = db.product.find({ where: { id } });
if (!product) {
notFound();
}

return (
<Boundary label="photo/[id]/page.tsx" className="flex flex-col gap-6">
<Link
href="/intercepting-routes"
className="inline-flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white"
>
<ChevronLeftIcon className="size-4" />
Back to gallery
</Link>

<div className="grid gap-6 lg:grid-cols-[minmax(0,20rem)_1fr]">
<div className="overflow-hidden rounded-2xl bg-gray-900/60 p-8">
<Image
src={`/shop/${product.image}`}
alt={product.name}
width={400}
height={400}
className="mx-auto brightness-150"
/>
</div>

<div className="flex flex-col gap-4">
<div className="text-sm uppercase tracking-[0.2em] text-gray-500">
Direct visit
</div>
<h1 className="text-3xl font-semibold text-white">{product.name}</h1>
<p className="max-w-xl text-sm leading-6 text-gray-400">
Loading this URL directly should render the standalone page. Navigating
from the gallery should keep the gallery visible and render this content
in the parallel modal slot instead.
</p>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit as the modal page — this JSX $ works but reads like a broken template literal:

Suggested change
</p>
{`$${product.price.toFixed(2)}`}

<div className="font-mono text-sm text-cyan-300">
${product.price.toFixed(2)}
</div>
</div>
</div>
</Boundary>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Intercepting Routes let you mask a target URL with the current layout during navigation.

- Click a product card below to navigate to its detail URL.
- During in-app navigation, `@modal/(.)photo/[id]` should render inside the modal slot.
- If you load the detail URL directly, `photo/[id]/page.tsx` renders as the full page instead.

- [Docs](https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes)
19 changes: 12 additions & 7 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,18 @@ export async function appRouter(
routes.push(...slotSubRoutes);

validateRoutePatterns(routes.map((route) => route.pattern));
validateRoutePatterns(
routes.flatMap((route) =>
route.parallelSlots.flatMap((slot) =>
slot.interceptingRoutes.map((intercept) => intercept.targetPattern),
),
),
);
const uniqueInterceptTargetPatterns = new Map<string, string>();
for (const route of routes) {
for (const slot of route.parallelSlots) {
for (const intercept of slot.interceptingRoutes) {
// Inherited slots can surface the same intercepting page on multiple
// child routes. De-dupe by page path so we only validate distinct
// physical intercept definitions while still rejecting real conflicts.
uniqueInterceptTargetPatterns.set(intercept.pagePath, intercept.targetPattern);
}
}
}
validateRoutePatterns(Array.from(uniqueInterceptTargetPatterns.values()));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old approach could have been wrapped in a set to achieve the same result I imagine.

I wonder if it could potentially be possible to have two conflicting pagePath values and therefore one overwrites the others targetPattern 🤔

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@james-elicx I changed it a bit. Mind taking another look?

Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could potentially be possible for two interceptions to target the same route pattern

What I meant when I wrote this was that you could potentially achieve the same desired outcome with:

    [...new Set(routes.flatMap((route) =>
      route.parallelSlots.flatMap((slot) =>
        slot.interceptingRoutes.map((intercept) => intercept.targetPattern),
      ),
    ))]

just deduping the array we originally created

what do you think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the PR to follow your suggestion.

After discussing it a bit more with AI, I think the targetPattern dedupe is probably fine for this PR. What I still want to validate is the behavior when there are multiple intercepting routes targeting the same route, for example:

app/feed/page.tsx
app/feed/@modal/(..)photo/[id]/page.tsx

app/profile/page.tsx
app/profile/@modal/(..)photo/[id]/page.tsx

app/photo/[id]/page.tsx

I’m going to put together a small demo around this case to compare the behavior in Next.js and vinext, just to make sure we’re matching the intended semantics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, I'll mark as draft for the moment while you're looking at that

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I opened #730 to track this issue separately.


// Sort: static routes first, then dynamic, then catch-all
routes.sort(compareRoutes);
Expand Down
Loading
Loading