diff --git a/.gitignore b/.gitignore
index 91fde374f..9d843637b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ dist/
out/
*.tsbuildinfo
.vite/
+.vinext/
.turbo/
.ecosystem-test/
.next/
diff --git a/examples/app-router-playground/app/_internal/_data.ts b/examples/app-router-playground/app/_internal/_data.ts
index 5006bbf48..9e736d334 100644
--- a/examples/app-router-playground/app/_internal/_data.ts
+++ b/examples/app-router-playground/app/_internal/_data.ts
@@ -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',
+ },
],
},
{
diff --git a/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx b/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx
new file mode 100644
index 000000000..3ca45f5a4
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/@modal/(.)photo/[id]/page.tsx
@@ -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 (
+
+
+
+
+
+ Intercepted in modal
+
+
{product.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+ ${product.price.toFixed(2)}
+
+
+ Refresh this URL to see the standalone detail page instead.
+
+
+
+
+
+ );
+}
diff --git a/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx b/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx
new file mode 100644
index 000000000..6ddf1b76f
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/@modal/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null;
+}
diff --git a/examples/app-router-playground/app/intercepting-routes/layout.tsx b/examples/app-router-playground/app/intercepting-routes/layout.tsx
new file mode 100644
index 000000000..c526da23d
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/layout.tsx
@@ -0,0 +1,46 @@
+'use cache';
+
+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 {
+ 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 (
+ <>
+
+
+
+
+
+
+ {children}
+
+
+ {modal}
+
+ >
+ );
+}
diff --git a/examples/app-router-playground/app/intercepting-routes/page.tsx b/examples/app-router-playground/app/intercepting-routes/page.tsx
new file mode 100644
index 000000000..f4c11b13d
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/page.tsx
@@ -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 (
+
+
+
+ Product gallery with modal interception
+
+
+ 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.
+
+
+
+
+ {products.map((product) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx b/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx
new file mode 100644
index 000000000..245a421a9
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/photo/[id]/page.tsx
@@ -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 (
+
+
+
+ Back to gallery
+
+
+
+
+
+
+
+
+
+ Direct visit
+
+
{product.name}
+
+ 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.
+
+
+ ${product.price.toFixed(2)}
+
+
+
+
+ );
+}
diff --git a/examples/app-router-playground/app/intercepting-routes/readme.mdx b/examples/app-router-playground/app/intercepting-routes/readme.mdx
new file mode 100644
index 000000000..abee6bbd3
--- /dev/null
+++ b/examples/app-router-playground/app/intercepting-routes/readme.mdx
@@ -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)
diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts
index c9142a36a..1937f6604 100644
--- a/packages/vinext/src/routing/app-router.ts
+++ b/packages/vinext/src/routing/app-router.ts
@@ -131,6 +131,18 @@ export function invalidateAppRouteCache(): void {
cachedPageExtensionsKey = null;
}
+export function collectInterceptTargetPatterns(routes: readonly AppRoute[]): string[] {
+ return [
+ ...new Set(
+ routes.flatMap((route) =>
+ route.parallelSlots.flatMap((slot) =>
+ slot.interceptingRoutes.map((intercept) => intercept.targetPattern),
+ ),
+ ),
+ ),
+ ];
+}
+
/**
* Scan the app/ directory and return a list of routes.
*/
@@ -173,13 +185,7 @@ 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),
- ),
- ),
- );
+ validateRoutePatterns(collectInterceptTargetPatterns(routes));
// Sort: static routes first, then dynamic, then catch-all
routes.sort(compareRoutes);
@@ -237,7 +243,11 @@ function discoverSlotSubRoutes(
// that useSelectedLayoutSegments() sees the correct segment list at runtime.
rawSegments: string[];
// Pre-computed URL parts, params, isDynamic from convertSegmentsToRouteParts.
- converted: { urlSegments: string[]; params: string[]; isDynamic: boolean };
+ converted: {
+ urlSegments: string[];
+ params: string[];
+ isDynamic: boolean;
+ };
slotPages: Map;
}
>();
diff --git a/tests/intercepting-routes-build.test.ts b/tests/intercepting-routes-build.test.ts
new file mode 100644
index 000000000..cc2b98ad4
--- /dev/null
+++ b/tests/intercepting-routes-build.test.ts
@@ -0,0 +1,153 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { createBuilder } from "vite";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+import vinext from "../packages/vinext/src/index.js";
+
+const tmpDirs: string[] = [];
+
+function writeFixtureFile(root: string, filePath: string, content: string) {
+ const absPath = path.join(root, filePath);
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
+ fs.writeFileSync(absPath, content);
+}
+
+async function buildApp(root: string) {
+ const builder = await createBuilder({
+ root,
+ configFile: false,
+ plugins: [vinext({ appDir: root })],
+ logLevel: "silent",
+ });
+ await builder.buildApp();
+}
+
+describe("App Router intercepting routes in production builds", () => {
+ afterEach(() => {
+ for (const dir of tmpDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("builds when an inherited modal slot intercepts the same target route as a standalone page", async () => {
+ // Ported from Next.js route interception behavior:
+ // test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
+ // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-build-"));
+ tmpDirs.push(root);
+
+ fs.symlinkSync(
+ path.resolve(import.meta.dirname, "../node_modules"),
+ path.join(root, "node_modules"),
+ "junction",
+ );
+
+ writeFixtureFile(
+ root,
+ "package.json",
+ JSON.stringify({ name: "vinext-intercept-build", private: true, type: "module" }, null, 2),
+ );
+ writeFixtureFile(
+ root,
+ "tsconfig.json",
+ JSON.stringify(
+ {
+ compilerOptions: {
+ target: "ES2022",
+ module: "ESNext",
+ moduleResolution: "bundler",
+ jsx: "react-jsx",
+ strict: true,
+ skipLibCheck: true,
+ types: ["vite/client", "@vitejs/plugin-rsc/types"],
+ },
+ include: ["app", "*.ts", "*.tsx"],
+ },
+ null,
+ 2,
+ ),
+ );
+ writeFixtureFile(
+ root,
+ "app/layout.tsx",
+ `import type { ReactNode } from "react";
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+`,
+ );
+ writeFixtureFile(
+ root,
+ "app/page.tsx",
+ `export default function HomePage() {
+ return home;
+}
+`,
+ );
+ writeFixtureFile(
+ root,
+ "app/intercepting-routes/layout.tsx",
+ `import type { ReactNode } from "react";
+
+export default function InterceptingLayout({
+ children,
+ modal,
+}: {
+ children: ReactNode;
+ modal: ReactNode;
+}) {
+ return (
+ <>
+