diff --git a/examples/app-router-cloudflare/pages/index.tsx b/examples/app-router-cloudflare/pages/pages-index.tsx
similarity index 52%
rename from examples/app-router-cloudflare/pages/index.tsx
rename to examples/app-router-cloudflare/pages/pages-index.tsx
index a016be350..c5ed14f3d 100644
--- a/examples/app-router-cloudflare/pages/index.tsx
+++ b/examples/app-router-cloudflare/pages/pages-index.tsx
@@ -1,4 +1,3 @@
-// Ensure app router works when a pages directory is present
export default function Page() {
return
pages index
;
}
diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts
index fc8aea3cb..4e17bd257 100644
--- a/packages/vinext/src/client/vinext-next-data.ts
+++ b/packages/vinext/src/client/vinext-next-data.ts
@@ -10,6 +10,24 @@ import { isUnknownRecord } from "../utils/record.js";
export type VinextLinkPrefetchRoute = {
canPrefetchLoadingShell: boolean;
+ documentOnly?: boolean;
+ isDynamic: boolean;
+ patternParts: string[];
+};
+
+/**
+ * Pages Router route pattern exposed to the client so the App Router's
+ * navigation runtime can decide whether a soft-navigated URL should be
+ * handled by Pages (hard nav) or App (RSC). Mirrors the public shape of
+ * `VinextLinkPrefetchRoute` so a single trie matcher handles both.
+ *
+ * `canPrefetchLoadingShell` is always `false` for Pages routes — Pages
+ * does not have a separate loading boundary and its prefetch surface is
+ * `_next/data//.json`.
+ */
+export type VinextPagesLinkPrefetchRoute = {
+ canPrefetchLoadingShell: false;
+ documentOnly?: boolean;
isDynamic: boolean;
patternParts: string[];
};
diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts
index 62aaedbd9..99a85e104 100644
--- a/packages/vinext/src/entries/app-browser-entry.ts
+++ b/packages/vinext/src/entries/app-browser-entry.ts
@@ -1,7 +1,11 @@
import { resolveClientRuntimeModule, resolveRuntimeEntryModule } from "./runtime-entry-module.js";
-import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js";
+import type {
+ VinextLinkPrefetchRoute,
+ VinextPagesLinkPrefetchRoute,
+} from "../client/vinext-next-data.js";
import type { AppRoute } from "../routing/app-router.js";
import type { RouteManifest } from "../routing/app-route-graph.js";
+import type { NextRewrite } from "../config/next-config.js";
/**
* Generate the virtual browser entry module.
@@ -13,16 +17,28 @@ import type { RouteManifest } from "../routing/app-route-graph.js";
export function generateBrowserEntry(
routes: readonly AppRoute[] = [],
routeManifest: RouteManifest | null = null,
+ pagesPrefetchRoutes: readonly VinextPagesLinkPrefetchRoute[] = [],
+ rewrites: { afterFiles: NextRewrite[]; beforeFiles: NextRewrite[]; fallback: NextRewrite[] } = {
+ afterFiles: [],
+ beforeFiles: [],
+ fallback: [],
+ },
): string {
const entryPath = resolveRuntimeEntryModule("app-browser-entry");
const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime");
- const prefetchRoutes: VinextLinkPrefetchRoute[] = routes
- .filter(isLinkPrefetchRoute)
- .map(toLinkPrefetchRoute);
+ const prefetchRoutes: VinextLinkPrefetchRoute[] = routes.map((route) =>
+ isLinkPrefetchRoute(route) ? toLinkPrefetchRoute(route) : toDocumentOnlyAppRoute(route),
+ );
return `import { registerNavigationRuntimeBootstrap } from ${JSON.stringify(navigationRuntimePath)};
window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)};
+// Pages route manifest for hybrid ownership decisions. In a hybrid
+// app+pages build the user can land on an App page, so the App browser
+// entry must also expose the Pages manifest (the Pages client entry does
+// the same — whichever entry runs first emits both globals).
+window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)};
+window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(rewrites)};
registerNavigationRuntimeBootstrap({
routeManifest: ${buildRouteManifestExpression(routeManifest)}
});
@@ -40,6 +56,15 @@ export function isLinkPrefetchRoute(route: AppRoute): boolean {
return route.routePath === null && route.layouts.length > 0;
}
+export function toDocumentOnlyAppRoute(route: AppRoute): VinextLinkPrefetchRoute {
+ return {
+ canPrefetchLoadingShell: false,
+ documentOnly: true,
+ patternParts: [...route.patternParts],
+ isDynamic: route.isDynamic,
+ };
+}
+
/** Project an `AppRoute` down to the public `VinextLinkPrefetchRoute` shape. */
export function toLinkPrefetchRoute(route: AppRoute): VinextLinkPrefetchRoute {
return {
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 80ffffaa5..4ee0c802b 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -1042,9 +1042,9 @@ export default __createAppRscHandler({
},
${
hasPagesDir
- ? `async renderPagesFallback({ isRscRequest, middlewareContext, request, url }) {
+ ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) {
return __renderPagesFallback(
- { isRscRequest, middlewareContext, request, url },
+ { allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url },
{
loadPagesEntry() {
return import.meta.viteRsc.loadModule("ssr", "index");
diff --git a/packages/vinext/src/entries/app-ssr-entry.ts b/packages/vinext/src/entries/app-ssr-entry.ts
index 439187f48..cc76719b8 100644
--- a/packages/vinext/src/entries/app-ssr-entry.ts
+++ b/packages/vinext/src/entries/app-ssr-entry.ts
@@ -20,7 +20,7 @@ export { default } from ${JSON.stringify(entryPath)};
${
hasPagesDir
? `
-export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";
+export { handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";
`
: ""
}`;
diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts
index 230161d9a..7ea03b854 100644
--- a/packages/vinext/src/entries/pages-client-entry.ts
+++ b/packages/vinext/src/entries/pages-client-entry.ts
@@ -10,16 +10,36 @@
* Extracted from index.ts.
*/
import {
+ apiRouter,
pagesRouter,
patternToNextFormat as pagesPatternToNextFormat,
type Route,
} from "../routing/pages-router.js";
import { createValidFileMatcher } from "../routing/file-matcher.js";
import { type ResolvedNextConfig } from "../config/next-config.js";
-import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js";
+import type {
+ VinextLinkPrefetchRoute,
+ VinextPagesLinkPrefetchRoute,
+} from "../client/vinext-next-data.js";
import { findFileWithExts } from "./pages-entry-helpers.js";
import { normalizePathSeparators } from "../utils/path.js";
+/**
+ * Project a Pages `Route` down to the public `VinextPagesLinkPrefetchRoute`
+ * shape used for client-side hybrid ownership decisions. Mirrors
+ * `toLinkPrefetchRoute` in `app-browser-entry.ts`.
+ *
+ * Lives here (not in `routing/pages-router.ts`) so the routing module
+ * stays free of `vitext/client` type imports.
+ */
+function toPagesLinkPrefetchRoute(route: Route): VinextPagesLinkPrefetchRoute {
+ return {
+ canPrefetchLoadingShell: false,
+ isDynamic: route.isDynamic,
+ patternParts: [...route.patternParts],
+ };
+}
+
export async function generateClientEntry(
pagesDir: string,
nextConfig: ResolvedNextConfig,
@@ -30,10 +50,15 @@ export async function generateClientEntry(
} = {},
): Promise {
const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
+ const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher);
const hasApp = appFilePath !== null;
const appPrefetchRoutes = options.appPrefetchRoutes ?? [];
+ const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = [
+ ...pageRoutes.map(toPagesLinkPrefetchRoute),
+ ...apiRoutes.map((route) => ({ ...toPagesLinkPrefetchRoute(route), documentOnly: true })),
+ ];
const instrumentationClientPath = options.instrumentationClientPath ?? null;
// Build a map of route pattern -> dynamic import.
@@ -129,6 +154,12 @@ window.__VINEXT_APP_LOADER__ = appLoader;
// when the user lands on an App Router page (see app-browser-entry.ts) — the
// two writes do not race because only one entry executes per page load.
window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)};
+// Pages route manifest, exposed so the App Router runtime can decide when
+// a soft-navigated URL is actually owned by Pages (and must hard-navigate
+// instead of issuing an RSC request). Set here AND in app-browser-entry.ts
+// so whichever entry runs first emits the Pages manifest.
+window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)};
+window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(nextConfig.rewrites)};
async function hydrate() {
const nextData = window.__NEXT_DATA__;
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index c55979ee2..f38c12862 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -344,6 +344,20 @@ export function matchPageRoute(url, request) {
return matchRoute(routeUrl, pageRoutes);
}
+export function matchApiRoute(url, request) {
+ const routeUrl = i18nConfig && request
+ ? resolvePagesI18nRequest(
+ url,
+ i18nConfig,
+ request.headers,
+ new URL(request.url).hostname,
+ vinextConfig.basePath,
+ vinextConfig.trailingSlash,
+ ).url
+ : url;
+ return matchRoute(routeUrl, apiRoutes);
+}
+
// ── Pages render orchestrator — delegates to server/pages-page-handler.ts ──
//
// All next/*-derived values are passed as closures so the handler module
diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts
index 799af2a96..16359cc32 100644
--- a/packages/vinext/src/index.ts
+++ b/packages/vinext/src/index.ts
@@ -15,7 +15,12 @@ import {
} from "./routing/pages-router.js";
import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js";
import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js";
-import { appRouteGraph, appRouter, invalidateAppRouteCache } from "./routing/app-router.js";
+import {
+ appRouteGraph,
+ appRouter,
+ invalidateAppRouteCache,
+ matchAppRoute,
+} from "./routing/app-router.js";
import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js";
import {
buildViteResolveExtensions,
@@ -46,6 +51,7 @@ import {
import {
generateBrowserEntry,
isLinkPrefetchRoute,
+ toDocumentOnlyAppRoute,
toLinkPrefetchRoute,
} from "./entries/app-browser-entry.js";
import {
@@ -103,6 +109,10 @@ import {
type PagesPipelineDeps,
type MiddlewareResult,
} from "./server/pages-request-pipeline.js";
+import {
+ pagesRouteHasPriorityOverAppRoute,
+ validateHybridRouteConflicts,
+} from "./server/hybrid-route-priority.js";
import { proxyExternalRequest } from "./config/config-matchers.js";
import { detectPackageManager } from "./utils/project.js";
import { isUnknownRecord as isRecord } from "./utils/record.js";
@@ -843,9 +853,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// with `{ __appRouter: true }`. See `pages-client-entry.ts` and issue
// #1526 for the Next.js parity rationale.
const appPrefetchRoutes = hasAppDir
- ? (await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher))
- .filter(isLinkPrefetchRoute)
- .map(toLinkPrefetchRoute)
+ ? (await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher)).map((route) =>
+ isLinkPrefetchRoute(route) ? toLinkPrefetchRoute(route) : toDocumentOnlyAppRoute(route),
+ )
: [];
return _generateClientEntry(pagesDir, nextConfig, fileMatcher, {
appPrefetchRoutes,
@@ -2390,7 +2400,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
return null;
},
- configResolved(config) {
+ async configResolved(config) {
// Provide the resolved config to the Sass-aware CSS Modules Loader so
// it can call Vite's `preprocessCSS` when processing SCSS files
// referenced by `composes: className from './file.module.scss'`.
@@ -2399,6 +2409,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// preprocessor options and `css.modules` settings are in place.
sassComposesLoader.setResolvedConfig(config);
+ if (config.command === "build" && hasAppDir && hasPagesDir) {
+ const [appRoutes, pageRoutes, apiRoutes] = await Promise.all([
+ appRouter(appDir, nextConfig?.pageExtensions, fileMatcher),
+ pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher),
+ apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher),
+ ]);
+ validateHybridRouteConflicts(
+ [...pageRoutes, ...apiRoutes].map((route) => ({
+ ...route,
+ sourcePath: path.relative(root, route.filePath),
+ })),
+ appRoutes
+ .filter((route) => route.pagePath !== null || route.routePath !== null)
+ .map((route) => ({
+ ...route,
+ sourcePath: path.relative(root, route.pagePath ?? route.routePath!),
+ })),
+ );
+ }
+
// When the user sets `ssr.external: true`, strip React entries from
// `environments.ssr.resolve.noExternal`. @vitejs/plugin-rsc populates
// this list via crawlFrameworkPkgs, but `noExternal` overrides
@@ -2684,7 +2714,34 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) {
const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher);
- return generateBrowserEntry(graph.routes, graph.routeManifest);
+ // In a hybrid build, the App browser entry also exposes the Pages
+ // route manifest so a user who lands on an App page can still
+ // see Pages ownership from a `` click.
+ const pagesPrefetchRoutes = hasPagesDir
+ ? [
+ ...(await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map(
+ (route) => ({
+ canPrefetchLoadingShell: false as const,
+ isDynamic: route.isDynamic,
+ patternParts: [...route.patternParts],
+ }),
+ ),
+ ...(await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map(
+ (route) => ({
+ canPrefetchLoadingShell: false as const,
+ documentOnly: true,
+ isDynamic: route.isDynamic,
+ patternParts: [...route.patternParts],
+ }),
+ ),
+ ]
+ : [];
+ return generateBrowserEntry(
+ graph.routes,
+ graph.routeManifest,
+ pagesPrefetchRoutes,
+ nextConfig.rewrites,
+ );
}
if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) {
return generateGoogleFontsVirtualModule(id, _fontGoogleShimPath);
@@ -3068,12 +3125,77 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
}
+ function invalidateHybridClientEntries() {
+ if (!hasAppDir || !hasPagesDir) return;
+ for (const env of Object.values(server.environments)) {
+ for (const id of [RESOLVED_CLIENT_ENTRY, RESOLVED_APP_BROWSER_ENTRY]) {
+ const mod = env.moduleGraph.getModuleById(id);
+ if (mod) env.moduleGraph.invalidateModule(mod);
+ }
+ }
+ server.ws.send({ type: "full-reload" });
+ }
+
+ function invalidatePagesServerEntry() {
+ for (const env of Object.values(server.environments)) {
+ const mod = env.moduleGraph.getModuleById(RESOLVED_SERVER_ENTRY);
+ if (mod) env.moduleGraph.invalidateModule(mod);
+ }
+ pagesRunner?.clearCache();
+ }
+
function invalidateAppRoutingModules() {
invalidateAppRouteCache();
invalidateRscEntryModule();
invalidateRootParamsModule();
}
+ let hybridRouteValidation: Promise = Promise.resolve();
+ let hybridRouteValidationError: Error | null = null;
+ function sendHybridRouteValidationError(error: Error) {
+ server.ws.send({
+ type: "error",
+ err: { message: error.message, stack: error.stack ?? error.message },
+ });
+ }
+ server.ws.on("connection", () => {
+ if (hybridRouteValidationError)
+ sendHybridRouteValidationError(hybridRouteValidationError);
+ });
+ function revalidateHybridRoutes() {
+ if (!hasAppDir || !hasPagesDir) return;
+ hybridRouteValidation = hybridRouteValidation
+ .catch(() => {})
+ .then(async () => {
+ const [appRoutes, pageRoutes, apiRoutes] = await Promise.all([
+ appRouter(appDir, nextConfig?.pageExtensions, fileMatcher),
+ pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher),
+ apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher),
+ ]);
+ validateHybridRouteConflicts(
+ [...pageRoutes, ...apiRoutes].map((route) => ({
+ ...route,
+ sourcePath: path.relative(root, route.filePath),
+ })),
+ appRoutes
+ .filter((route) => route.pagePath !== null || route.routePath !== null)
+ .map((route) => ({
+ ...route,
+ sourcePath: path.relative(root, route.pagePath ?? route.routePath!),
+ })),
+ );
+ if (hybridRouteValidationError) {
+ hybridRouteValidationError = null;
+ server.ws.send({ type: "full-reload" });
+ }
+ })
+ .catch((error) => {
+ const err = error instanceof Error ? error : new Error(String(error));
+ hybridRouteValidationError = err;
+ sendHybridRouteValidationError(err);
+ });
+ }
+
let appRouteTypeGeneration: Promise | null = null;
let appRouteTypeGenerationPending = false;
@@ -3109,6 +3231,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}
regenerateAppRouteTypes();
+ revalidateHybridRoutes();
// Node throws on unhandled 'error' events on sockets. When a browser
// drops the connection mid-response (common in dev: HMR triggers a
@@ -3123,21 +3246,37 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
});
server.watcher.on("add", (filePath: string) => {
+ let routeChanged = false;
if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) {
invalidateRouteCache(pagesDir);
+ routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
regenerateAppRouteTypes();
+ routeChanged = true;
+ }
+ if (routeChanged) {
+ invalidatePagesServerEntry();
+ invalidateHybridClientEntries();
+ revalidateHybridRoutes();
}
});
server.watcher.on("unlink", (filePath: string) => {
+ let routeChanged = false;
if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) {
invalidateRouteCache(pagesDir);
+ routeChanged = true;
}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
regenerateAppRouteTypes();
+ routeChanged = true;
+ }
+ if (routeChanged) {
+ invalidatePagesServerEntry();
+ invalidateHybridClientEntries();
+ revalidateHybridRoutes();
}
});
@@ -3750,6 +3889,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// request, wiping the hybrid app+pages middleware context
// (VINEXT_MW_CTX_HEADER, set on req.headers) that the app RSC plugin reads.
const apiMatch = matchRoute(pipelineResult.apiUrl, apiRoutes);
+ if (apiMatch && hasAppDir && appDir) {
+ const appRoutes = await appRouter(
+ appDir,
+ nextConfig?.pageExtensions,
+ fileMatcher,
+ );
+ const appMatch = matchAppRoute(pipelineResult.apiUrl, appRoutes);
+ if (
+ appMatch &&
+ !pagesRouteHasPriorityOverAppRoute(apiMatch.route, appMatch.route)
+ ) {
+ return next();
+ }
+ }
if (apiMatch) {
flushStagedHeaders();
flushRequestHeaders();
@@ -3785,11 +3938,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
const routes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher);
// Hybrid app+pages dev: if the resolved URL matches no pages route
// and an app/ dir exists, defer to the RSC plugin (app routes live
- // there). Mirrors the original hasAppDir fallthrough gates that the
- // refactor centralised into the pipeline owner.
- const renderMatch = matchRoute(pipelineResult.resolvedUrl.split("?")[0], routes);
- if (!renderMatch && hasAppDir) {
- return next();
+ // there). If both routers match, apply Next.js's merged route
+ // precedence before choosing which plugin owns the request.
+ const resolvedPathname = pipelineResult.resolvedUrl
+ .split("#", 1)[0]
+ .split("?", 1)[0];
+ const renderMatch = matchRoute(resolvedPathname, routes);
+ if (hasAppDir && appDir) {
+ if (!renderMatch) {
+ return next();
+ }
+ const appRoutes = await appRouter(
+ appDir,
+ nextConfig?.pageExtensions,
+ fileMatcher,
+ );
+ const appMatch = matchAppRoute(resolvedPathname, appRoutes);
+ if (
+ appMatch &&
+ !pagesRouteHasPriorityOverAppRoute(renderMatch.route, appMatch.route)
+ ) {
+ return next();
+ }
}
const handler = createSSRHandler(
server,
diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts
index 2c4faaf18..333953376 100644
--- a/packages/vinext/src/routing/utils.ts
+++ b/packages/vinext/src/routing/utils.ts
@@ -97,6 +97,70 @@ export function sortRoutes(routes: T[]): T[] {
});
}
+/**
+ * Single source of truth for hybrid App/Pages route ownership.
+ *
+ * Mirrors Next.js's DefaultRouteMatcherManager ordering: Pages providers
+ * are registered before App providers, then merged dynamic matchers sort
+ * together. Returns the router that should own a request/navigation to
+ * a URL that matched BOTH routers.
+ *
+ * Centralised so the server's request handling and the client's link /
+ * prefetch / programmatic-navigation paths all reach the same owner for
+ * the same (pages pattern, app pattern) pair. This intentionally implements
+ * Next.js's segment-tree ordering directly instead of vinext's broader
+ * `sortRoutes()` score heuristic. It only arbitrates two routes that already
+ * matched the same URL; each router's own trie ordering remains unchanged.
+ *
+ * Usage:
+ * compareHybridRoutePatterns("/:slug", true, "/:slug", true) // → "pages"
+ * compareHybridRoutePatterns("/_sites/:slug*", true, "/:slug*", true) // → "pages"
+ * compareHybridRoutePatterns("/:path+", true, "/dashboard", false) // → "app"
+ * compareHybridRoutePatterns("/", false, "/", false) // → "app"
+ */
+export function compareHybridRoutePatterns(
+ pagesPattern: string,
+ pagesIsDynamic: boolean,
+ appPattern: string,
+ appIsDynamic: boolean,
+): "app" | "pages" {
+ if (pagesPattern === appPattern) {
+ throw new Error(`Conflicting app and page routes found for "${pagesPattern}"`);
+ }
+
+ // Static-only paths: if Pages is static and App is also static, both have
+ // a literal route, but the App's catch-all is irrelevant — App still owns
+ // the literal hit (Next.js registers App providers after Pages but a
+ // static App segment always beats a static Pages segment when both match
+ // the same URL). If App is dynamic, Pages wins because Pages has the
+ // literal and App only has a catch-all.
+ if (!pagesIsDynamic) return appIsDynamic ? "pages" : "app";
+ // Pages is dynamic, App is static: App's literal always wins.
+ if (!appIsDynamic) return "app";
+ const pagesSegments = pagesPattern.split("/").filter(Boolean);
+ const appSegments = appPattern.split("/").filter(Boolean);
+ const segmentRank = (segment: string): number => {
+ if (!segment.startsWith(":")) return 0;
+ if (segment.endsWith("*")) return 3;
+ if (segment.endsWith("+")) return 2;
+ return 1;
+ };
+
+ for (let index = 0; index < Math.min(pagesSegments.length, appSegments.length); index++) {
+ const pagesRank = segmentRank(pagesSegments[index]);
+ const appRank = segmentRank(appSegments[index]);
+ if (pagesRank !== appRank) return pagesRank < appRank ? "pages" : "app";
+ }
+
+ if (pagesSegments.length !== appSegments.length) {
+ return pagesSegments.length < appSegments.length ? "pages" : "app";
+ }
+
+ // Matching dynamic routes with the same structural specificity retain
+ // provider order. Next.js registers Pages providers before App providers.
+ return "pages";
+}
+
// Matches literal delimiter characters and their percent-encoded equivalents.
// Literal `/`, `#`, `?` can appear after decodeURIComponent when the input was
// originally encoded (e.g. `%2F` → `/`); they are re-encoded to preserve their
diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts
index 1cb222b55..57aed8aba 100644
--- a/packages/vinext/src/server/app-pages-bridge.ts
+++ b/packages/vinext/src/server/app-pages-bridge.ts
@@ -1,7 +1,10 @@
import type { AppMiddlewareContext } from "./app-middleware.js";
+import { pagesRouteHasPriorityOverAppRoute } from "./hybrid-route-priority.js";
export type PagesEntry = {
handleApiRoute?: (request: Request, url: string) => Promise | Response;
+ matchApiRoute?: (url: string, request: Request) => PagesRouteMatch | null;
+ matchPageRoute?: (url: string, request: Request) => PagesRouteMatch | null;
renderPage?: (
request: Request,
url: string,
@@ -11,6 +14,20 @@ export type PagesEntry = {
) => Promise | Response;
};
+type PagesRouteMatch = {
+ route: {
+ isDynamic: boolean;
+ pattern: string;
+ };
+};
+
+type AppRouteMatch = {
+ route: {
+ isDynamic: boolean;
+ pattern: string;
+ };
+};
+
type RenderPagesFallbackDependencies = {
loadPagesEntry: () => Promise | PagesEntry;
buildRequestHeaders: (
@@ -41,8 +58,12 @@ type RenderPagesFallbackDependencies = {
};
type RenderPagesFallbackOptions = {
+ allowRscDocumentFallback?: boolean;
+ appRouteMatch?: AppRouteMatch | null;
isRscRequest: boolean;
+ matchKind?: "dynamic" | "static";
middlewareContext: AppMiddlewareContext;
+ pathname?: string;
request: Request;
url: URL;
};
@@ -54,7 +75,16 @@ export async function renderPagesFallback(
options: RenderPagesFallbackOptions,
dependencies: RenderPagesFallbackDependencies,
): Promise {
- const { isRscRequest, middlewareContext, request, url } = options;
+ const {
+ allowRscDocumentFallback = false,
+ appRouteMatch = null,
+ isRscRequest,
+ matchKind,
+ middlewareContext,
+ pathname = options.url.pathname,
+ request,
+ url,
+ } = options;
const {
loadPagesEntry,
buildRequestHeaders,
@@ -63,7 +93,7 @@ export async function renderPagesFallback(
getDraftModeCookieHeader,
} = dependencies;
- if (isRscRequest) return null;
+ if (isRscRequest && !allowRscDocumentFallback) return null;
const pagesEntry = await loadPagesEntry();
@@ -84,10 +114,27 @@ export async function renderPagesFallback(
pagesRequest = new Request(request.url, pagesRequestInit);
}
- const pagesUrl = decodePathParams(url.pathname) + (url.search || "");
- const pagesPathname = url.pathname;
+ const queryIndex = pathname.indexOf("?");
+ const pagesPathname = queryIndex === -1 ? pathname : pathname.slice(0, queryIndex);
+ const pagesSearch = queryIndex === -1 ? url.search || "" : pathname.slice(queryIndex);
+ const pagesUrl = decodePathParams(pagesPathname) + pagesSearch;
if (pagesPathname.startsWith("/api/") || pagesPathname === "/api") {
if (typeof pagesEntry.handleApiRoute !== "function") return null;
+ const hasApiMatcher = typeof pagesEntry.matchApiRoute === "function";
+ const apiMatch = hasApiMatcher
+ ? (pagesEntry.matchApiRoute?.(pagesUrl, pagesRequest) ?? null)
+ : null;
+ if (hasApiMatcher && apiMatch === null) return null;
+ if (apiMatch !== null && matchKind === "static" && apiMatch.route.isDynamic) return null;
+ if (apiMatch !== null && matchKind === "dynamic" && !apiMatch.route.isDynamic) return null;
+ if (appRouteMatch !== null) {
+ if (
+ apiMatch === null ||
+ !pagesRouteHasPriorityOverAppRoute(apiMatch.route, appRouteMatch.route)
+ ) {
+ return null;
+ }
+ }
const pagesApiResponse = await pagesEntry.handleApiRoute(pagesRequest, pagesUrl);
const draftCookie = getDraftModeCookieHeader();
return applyDraftModeCookie(
@@ -97,6 +144,19 @@ export async function renderPagesFallback(
}
if (typeof pagesEntry.renderPage !== "function") return null;
+ const hasPageMatcher = typeof pagesEntry.matchPageRoute === "function";
+ const pageMatch = hasPageMatcher
+ ? (pagesEntry.matchPageRoute?.(pagesUrl, pagesRequest) ?? null)
+ : null;
+ if (hasPageMatcher && pageMatch === null) return null;
+ if (pageMatch !== null && matchKind === "static" && pageMatch.route.isDynamic) return null;
+ if (pageMatch !== null && matchKind === "dynamic" && !pageMatch.route.isDynamic) return null;
+ if (
+ appRouteMatch !== null &&
+ (pageMatch === null || !pagesRouteHasPriorityOverAppRoute(pageMatch.route, appRouteMatch.route))
+ ) {
+ return null;
+ }
const pagesRes = await pagesEntry.renderPage(
pagesRequest,
pagesUrl,
@@ -104,7 +164,7 @@ export async function renderPagesFallback(
undefined,
middlewareContext.requestHeaders,
);
- if (pagesRes.status === 404) return null;
+ if (pagesRes.status === 404 && pageMatch === null) return null;
return applyDraftModeCookie(pagesRes, getDraftModeCookieHeader());
}
diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts
index e9d560ece..02ce80d46 100644
--- a/packages/vinext/src/server/app-rsc-handler.ts
+++ b/packages/vinext/src/server/app-rsc-handler.ts
@@ -43,6 +43,7 @@ import {
resolveInvalidRscCacheBustingRequest,
stripRscCacheBustingSearchParam,
stripRscSuffix,
+ VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM,
} from "./app-rsc-cache-busting.js";
import { finalizeAppRscResponse } from "./app-rsc-response-finalizer.js";
import { normalizeRscRequest } from "./app-rsc-request-normalization.js";
@@ -231,8 +232,12 @@ type RenderNotFoundOptions = {
};
type RenderPagesFallbackOptions = {
+ allowRscDocumentFallback?: boolean;
+ appRouteMatch?: { route: { isDynamic: boolean; pattern: string } } | null;
isRscRequest: boolean;
+ matchKind?: "dynamic" | "static";
middlewareContext: AppRscMiddlewareContext;
+ pathname?: string;
request: Request;
url: URL;
};
@@ -354,18 +359,21 @@ async function applyRewrite(
return rewritten;
}
-function applyInternalRewriteDestination(rewritten: string, url: URL): string {
- const merged = mergeRewriteQuery(`${url.pathname}${url.search}`, rewritten);
- const hashIndex = merged.indexOf("#");
- const beforeHash = hashIndex === -1 ? merged : merged.slice(0, hashIndex);
- const queryIndex = beforeHash.indexOf("?");
- if (queryIndex === -1) {
- url.search = "";
- return beforeHash;
- }
+function requestContextForResolvedUrl(
+ requestContext: RequestContext,
+ resolvedUrl: string,
+ baseUrl: URL,
+): RequestContext {
+ return {
+ cookies: requestContext.cookies,
+ headers: requestContext.headers,
+ host: requestContext.host,
+ query: new URL(resolvedUrl, baseUrl).searchParams,
+ };
+}
- url.search = beforeHash.slice(queryIndex);
- return beforeHash.slice(0, queryIndex);
+function pathnameForResolvedUrl(resolvedUrl: string): string {
+ return resolvedUrl.split("#", 1)[0].split("?", 1)[0];
}
function applyConfigHeadersToMiddlewareRedirect(
@@ -458,6 +466,8 @@ async function handleAppRscRequest(
clientReuseManifest,
} = normalized;
let { pathname, cleanPathname } = normalized;
+ let resolvedUrl = cleanPathname + url.search;
+ const getResolvedSearchParams = () => new URL(resolvedUrl, url).searchParams;
// Canonical (external) pathname the user requested. Middleware rewrites and
// next.config.js rewrites mutate `cleanPathname` so internal route matching
// can find the destination page, but hooks like `usePathname()` must reflect
@@ -546,6 +556,7 @@ async function handleAppRscRequest(
requestHeaders: null,
status: null,
};
+ let didMiddlewareRewrite = false;
if (options.middlewareModule) {
const middlewareResult = await applyAppMiddleware({
@@ -569,9 +580,11 @@ async function handleAppRscRequest(
}
cleanPathname = middlewareResult.cleanPathname;
+ didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname;
if (middlewareResult.search !== null) {
url.search = middlewareResult.search;
}
+ resolvedUrl = cleanPathname + url.search;
}
const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers);
@@ -582,20 +595,29 @@ async function handleAppRscRequest(
// itself continues to use the un-prefixed `cleanPathname` because App
// Router files live under `app/...` with no locale segment. See issue
// #1336 item 4 / pages-i18n.normalizeDefaultLocalePathname.
- const beforeFilesRewrite = await applyRewrite(
- {
- basePathState,
- clearRequestContext: options.clearRequestContext,
- // External RSC rewrites must forward the validated `_rsc` token so the
- // destination server can validate the request without the original URL.
- request: normalizedUserlandRequest,
- requestContext: postMiddlewareRequestContext,
- rewrites: options.configRewrites.beforeFiles,
- },
- matchPathname(cleanPathname),
- );
- if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite;
- if (beforeFilesRewrite) cleanPathname = applyInternalRewriteDestination(beforeFilesRewrite, url);
+ for (const rewrite of options.configRewrites.beforeFiles) {
+ const beforeFilesRewrite = await applyRewrite(
+ {
+ basePathState,
+ clearRequestContext: options.clearRequestContext,
+ // External RSC rewrites must forward the validated `_rsc` token so the
+ // destination server can validate the request without the original URL.
+ request: normalizedUserlandRequest,
+ requestContext: requestContextForResolvedUrl(
+ postMiddlewareRequestContext,
+ resolvedUrl,
+ url,
+ ),
+ rewrites: [rewrite],
+ },
+ matchPathname(cleanPathname),
+ );
+ if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite;
+ if (beforeFilesRewrite) {
+ resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite);
+ cleanPathname = pathnameForResolvedUrl(resolvedUrl);
+ }
+ }
if (isImageOptimizationPath(cleanPathname)) {
const imageRedirect = resolveDevImageRedirect(
@@ -641,10 +663,13 @@ async function handleAppRscRequest(
}
stripRscCacheBustingSearchParam(url);
+ const resolved = new URL(resolvedUrl, url);
+ stripRscCacheBustingSearchParam(resolved);
+ resolvedUrl = resolved.pathname + resolved.search + resolved.hash;
options.setNavigationContext({
pathname: canonicalPathname,
- searchParams: url.searchParams,
+ searchParams: getResolvedSearchParams(),
params: {},
});
@@ -697,46 +722,106 @@ async function handleAppRscRequest(
middlewareContext,
mountedSlotsHeader,
request,
- searchParams: url.searchParams,
+ searchParams: getResolvedSearchParams(),
});
if (serverActionResponse) return serverActionResponse;
let match = preActionMatch;
+ const renderPagesForMatchKind = async (
+ matchKind: "dynamic" | "static",
+ ): Promise =>
+ match === null || match.route.isDynamic
+ ? ((await options.renderPagesFallback?.({
+ appRouteMatch: match ?? null,
+ allowRscDocumentFallback: didMiddlewareRewrite,
+ isRscRequest,
+ matchKind,
+ middlewareContext,
+ pathname: resolvedUrl,
+ request,
+ url,
+ })) ?? null)
+ : null;
+ const staticPagesFallbackResponse = await renderPagesForMatchKind("static");
+ if (staticPagesFallbackResponse) {
+ options.clearRequestContext();
+ return staticPagesFallbackResponse;
+ }
if (!match || match.route.isDynamic) {
- const afterFilesRewrite = await applyRewrite(
- {
- basePathState,
- clearRequestContext: options.clearRequestContext,
- // External RSC rewrites must forward the validated `_rsc` token.
- request: normalizedUserlandRequest,
- requestContext: postMiddlewareRequestContext,
- rewrites: options.configRewrites.afterFiles,
- },
- matchPathname(cleanPathname),
- );
- if (afterFilesRewrite instanceof Response) return afterFilesRewrite;
- if (afterFilesRewrite) {
- cleanPathname = applyInternalRewriteDestination(afterFilesRewrite, url);
+ for (const rewrite of options.configRewrites.afterFiles) {
+ const afterFilesRewrite = await applyRewrite(
+ {
+ basePathState,
+ clearRequestContext: options.clearRequestContext,
+ // External RSC rewrites must forward the validated `_rsc` token.
+ request: normalizedUserlandRequest,
+ requestContext: requestContextForResolvedUrl(
+ postMiddlewareRequestContext,
+ resolvedUrl,
+ url,
+ ),
+ rewrites: [rewrite],
+ },
+ matchPathname(cleanPathname),
+ );
+ if (afterFilesRewrite instanceof Response) return afterFilesRewrite;
+ if (!afterFilesRewrite) continue;
+ resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite);
+ cleanPathname = pathnameForResolvedUrl(resolvedUrl);
match = options.matchRoute(cleanPathname);
+ const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
+ if (rewrittenStaticPagesResponse) {
+ options.clearRequestContext();
+ return rewrittenStaticPagesResponse;
+ }
+ const rewrittenDynamicPagesResponse = await renderPagesForMatchKind("dynamic");
+ if (rewrittenDynamicPagesResponse) {
+ options.clearRequestContext();
+ return rewrittenDynamicPagesResponse;
+ }
+ if (match) break;
}
}
+ const dynamicPagesFallbackResponse = await renderPagesForMatchKind("dynamic");
+ if (dynamicPagesFallbackResponse) {
+ options.clearRequestContext();
+ return dynamicPagesFallbackResponse;
+ }
+
if (!match) {
- const fallbackRewrite = await applyRewrite(
- {
- basePathState,
- clearRequestContext: options.clearRequestContext,
- // External RSC rewrites must forward the validated `_rsc` token.
- request: normalizedUserlandRequest,
- requestContext: postMiddlewareRequestContext,
- rewrites: options.configRewrites.fallback,
- },
- matchPathname(cleanPathname),
- );
- if (fallbackRewrite instanceof Response) return fallbackRewrite;
- if (fallbackRewrite) {
- cleanPathname = applyInternalRewriteDestination(fallbackRewrite, url);
+ for (const rewrite of options.configRewrites.fallback) {
+ const fallbackRewrite = await applyRewrite(
+ {
+ basePathState,
+ clearRequestContext: options.clearRequestContext,
+ // External RSC rewrites must forward the validated `_rsc` token.
+ request: normalizedUserlandRequest,
+ requestContext: requestContextForResolvedUrl(
+ postMiddlewareRequestContext,
+ resolvedUrl,
+ url,
+ ),
+ rewrites: [rewrite],
+ },
+ matchPathname(cleanPathname),
+ );
+ if (fallbackRewrite instanceof Response) return fallbackRewrite;
+ if (!fallbackRewrite) continue;
+ resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
+ cleanPathname = pathnameForResolvedUrl(resolvedUrl);
match = options.matchRoute(cleanPathname);
+ const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static");
+ if (rewrittenStaticPagesResponse) {
+ options.clearRequestContext();
+ return rewrittenStaticPagesResponse;
+ }
+ const rewrittenDynamicPagesResponse = await renderPagesForMatchKind("dynamic");
+ if (rewrittenDynamicPagesResponse) {
+ options.clearRequestContext();
+ return rewrittenDynamicPagesResponse;
+ }
+ if (match) break;
}
}
@@ -753,17 +838,6 @@ async function handleAppRscRequest(
return new Response("", { status: 404 });
}
- const pagesFallbackResponse = await options.renderPagesFallback?.({
- isRscRequest,
- middlewareContext,
- request,
- url,
- });
- if (pagesFallbackResponse) {
- options.clearRequestContext();
- return pagesFallbackResponse;
- }
-
const renderedNotFoundResponse = await options.renderNotFound({
isRscRequest,
middlewareContext,
@@ -792,6 +866,7 @@ async function handleAppRscRequest(
const prerenderRouteParams = prerenderRouteParamsMatch?.params ?? null;
const isPrerenderFallbackShell = prerenderRouteParamsMatch?.kind === "fallback-shell";
const renderParams = prerenderRouteParams ?? params;
+ const resolvedSearchParams = getResolvedSearchParams();
const runtimeFallbackShells =
options.cacheComponents === true &&
request.method === "GET" &&
@@ -809,7 +884,7 @@ async function handleAppRscRequest(
: [];
options.setNavigationContext({
pathname: canonicalPathname,
- searchParams: url.searchParams,
+ searchParams: resolvedSearchParams,
params: renderParams,
});
const rootParams = pickRootParams(renderParams, route.rootParamNames);
@@ -826,6 +901,14 @@ async function handleAppRscRequest(
const routeHandlerRequest = isEdgeRouteHandler(route.routeHandler)
? userlandRequest
: normalizedUserlandRequest;
+ const routeHandlerUrl = new URL(routeHandlerRequest.url);
+ const internalRscValues = isEdgeRouteHandler(route.routeHandler)
+ ? []
+ : routeHandlerUrl.searchParams.getAll(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM);
+ routeHandlerUrl.search = resolvedSearchParams.toString();
+ for (const internalRscValue of internalRscValues) {
+ routeHandlerUrl.searchParams.append(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, internalRscValue);
+ }
return options.dispatchMatchedRouteHandler({
cleanPathname,
middlewareContext,
@@ -834,9 +917,9 @@ async function handleAppRscRequest(
// object (always `{}` for non-dynamic) so `useParams()` etc. still see
// an object shape; only the user-facing handler context surfaces null.
params: route.isDynamic ? renderParams : null,
- request: routeHandlerRequest,
+ request: new Request(routeHandlerUrl, routeHandlerRequest),
route,
- searchParams: url.searchParams,
+ searchParams: resolvedSearchParams,
});
}
@@ -869,7 +952,7 @@ async function handleAppRscRequest(
request,
route,
scriptNonce,
- searchParams: url.searchParams,
+ searchParams: resolvedSearchParams,
renderMode,
});
diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts
new file mode 100644
index 000000000..1be6981c1
--- /dev/null
+++ b/packages/vinext/src/server/hybrid-route-priority.ts
@@ -0,0 +1,62 @@
+import { compareHybridRoutePatterns } from "../routing/utils.js";
+import { validateRoutePatterns } from "../routing/route-validation.js";
+
+export type HybridRoutePriorityRoute = {
+ isDynamic: boolean;
+ pattern: string;
+ sourcePath?: string | null;
+};
+
+export function validateHybridRouteConflicts(
+ pagesRoutes: readonly HybridRoutePriorityRoute[],
+ appRoutes: readonly HybridRoutePriorityRoute[],
+): void {
+ const pagesByPattern = new Map(pagesRoutes.map((route) => [route.pattern, route]));
+ const conflicts = appRoutes.flatMap((appRoute) => {
+ const pagesRoute = pagesByPattern.get(appRoute.pattern);
+ return pagesRoute === undefined ? [] : [[pagesRoute, appRoute] as const];
+ });
+ if (conflicts.length > 0) {
+ const message = `Conflicting app and page file${conflicts.length === 1 ? " was" : "s were"} found, please remove the conflicting files to continue:`;
+ throw new Error(
+ `${message}\n${conflicts
+ .map(
+ ([pagesRoute, appRoute]) =>
+ ` "${pagesRoute.sourcePath ?? pagesRoute.pattern}" - "${appRoute.sourcePath ?? appRoute.pattern}"`,
+ )
+ .join("\n")}`,
+ );
+ }
+
+ validateRoutePatterns([
+ ...pagesRoutes.map((route) => route.pattern),
+ ...appRoutes.map((route) => route.pattern),
+ ]);
+}
+
+/**
+ * Return whether a matched Pages Router route should own the request instead
+ * of a matched App Router route.
+ *
+ * Next.js registers Pages providers before App providers, then sorts all
+ * dynamic route pathnames together in DefaultRouteMatcherManager. Vinext keeps
+ * separate route tries for each router, so the hybrid boundary needs to apply
+ * that same cross-router ordering after both routers have produced their best
+ * local match. The decision itself lives in
+ * `routing/utils.ts#compareHybridRoutePatterns` so the server and client
+ * always reach the same answer.
+ */
+export function pagesRouteHasPriorityOverAppRoute(
+ pagesRoute: HybridRoutePriorityRoute,
+ appRoute: HybridRoutePriorityRoute | null,
+): boolean {
+ if (appRoute === null) return true;
+ return (
+ compareHybridRoutePatterns(
+ pagesRoute.pattern,
+ pagesRoute.isDynamic,
+ appRoute.pattern,
+ appRoute.isDynamic,
+ ) === "pages"
+ );
+}
diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts
index 526c41284..15502d505 100644
--- a/packages/vinext/src/server/pages-request-pipeline.ts
+++ b/packages/vinext/src/server/pages-request-pipeline.ts
@@ -363,7 +363,12 @@ export async function runPagesRequest(
{ preserveCredentialHeaders: isExternalUrl(resolvedUrl) },
);
request = postMwReq;
- let resolvedPathname = resolvedUrl.split("?")[0];
+ const pathnameForResolvedUrl = (value: string): string => value.split("#", 1)[0].split("?", 1)[0];
+ const rewriteRequestContext = (): RequestContext => ({
+ ...postMwReqCtx,
+ query: new URL(resolvedUrl, url).searchParams,
+ });
+ let resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
const matchResolvedPathname = (p: string): string =>
i18nConfig ? normalizeDefaultLocalePathname(p, i18nConfig, { hostname: requestHostname }) : p;
@@ -408,22 +413,23 @@ export async function runPagesRequest(
}
// Step 9: beforeFiles rewrites
+ // Next.js server-utils.ts applies every beforeFiles rule in sequence and
+ // continues afterFiles/fallback rules until a destination resolves.
let configRewriteFired = false;
- if (configRewrites.beforeFiles?.length) {
- for (const rewrite of configRewrites.beforeFiles) {
- const rewritten = matchRewrite(
- matchResolvedPathname(resolvedPathname),
- [rewrite],
- postMwReqCtx,
- basePathState,
- );
- if (!rewritten) continue;
+ for (const rewrite of configRewrites.beforeFiles ?? []) {
+ const rewritten = matchRewrite(
+ matchResolvedPathname(resolvedPathname),
+ [rewrite],
+ rewriteRequestContext(),
+ basePathState,
+ );
+ if (rewritten) {
if (isExternalUrl(rewritten)) {
// Bare proxy — no middleware-header merge (see Step 8 asymmetry note).
return { type: "response", response: await proxyExternal(request, rewritten) };
}
resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten);
- resolvedPathname = resolvedUrl.split("?")[0];
+ resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
configRewriteFired = true;
}
}
@@ -474,25 +480,29 @@ export async function runPagesRequest(
}
// Step 12: afterFiles rewrites
- const pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null;
+ let pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null;
// matchPageRoute is a route-table scan; only re-run it below if afterFiles
// actually rewrote resolvedPathname (the common case leaves it unchanged).
let resolvedPathnameChanged = false;
- if ((!pageMatch || pageMatch.route.isDynamic) && configRewrites.afterFiles?.length) {
- const rewritten = matchRewrite(
- matchResolvedPathname(resolvedPathname),
- configRewrites.afterFiles,
- postMwReqCtx,
- basePathState,
- );
- if (rewritten) {
- if (isExternalUrl(rewritten)) {
- // Bare proxy — no middleware-header merge (see Step 8 asymmetry note).
- return { type: "response", response: await proxyExternal(request, rewritten) };
+ if (!pageMatch || pageMatch.route.isDynamic) {
+ for (const rewrite of configRewrites.afterFiles ?? []) {
+ const rewritten = matchRewrite(
+ matchResolvedPathname(resolvedPathname),
+ [rewrite],
+ rewriteRequestContext(),
+ basePathState,
+ );
+ if (rewritten) {
+ if (isExternalUrl(rewritten)) {
+ // Bare proxy — no middleware-header merge (see Step 8 asymmetry note).
+ return { type: "response", response: await proxyExternal(request, rewritten) };
+ }
+ resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten);
+ resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
+ resolvedPathnameChanged = true;
+ pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null;
+ if (pageMatch) break;
}
- resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten);
- resolvedPathname = resolvedUrl.split("?")[0];
- resolvedPathnameChanged = true;
}
}
@@ -512,19 +522,16 @@ export async function runPagesRequest(
// Step 13: Render + fallback rewrites
if (typeof deps.renderPage === "function") {
// Reuse the Step 12 match unless afterFiles changed the pathname.
- let renderPageMatch = resolvedPathnameChanged
- ? deps.matchPageRoute
- ? deps.matchPageRoute(resolvedPathname, request)
- : null
- : pageMatch;
+ let renderPageMatch = pageMatch;
if ((isDataReq || isDataRequest) && !renderPageMatch && configRewrites.fallback?.length) {
- const fallbackRewrite = matchRewrite(
- matchResolvedPathname(resolvedPathname),
- configRewrites.fallback,
- postMwReqCtx,
- basePathState,
- );
- if (fallbackRewrite) {
+ for (const rewrite of configRewrites.fallback) {
+ const fallbackRewrite = matchRewrite(
+ matchResolvedPathname(resolvedPathname),
+ [rewrite],
+ rewriteRequestContext(),
+ basePathState,
+ );
+ if (!fallbackRewrite) continue;
if (isExternalUrl(fallbackRewrite)) {
return {
type: "response",
@@ -532,11 +539,12 @@ export async function runPagesRequest(
};
}
resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
- resolvedPathname = resolvedUrl.split("?")[0];
+ resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
renderPageMatch = deps.matchPageRoute
? deps.matchPageRoute(resolvedPathname, request)
: null;
refreshDataRewriteHeader();
+ if (renderPageMatch) break;
}
}
// A data request must not defer-render the error page or run fallback rewrites.
@@ -570,13 +578,14 @@ export async function runPagesRequest(
// Fallback rewrites if 404 + deferred
let matchedFallbackRewrite = false;
if (response.status === 404 && shouldDeferErrorPageOnMiss && configRewrites.fallback?.length) {
- const fallbackRewrite = matchRewrite(
- matchResolvedPathname(resolvedPathname),
- configRewrites.fallback,
- postMwReqCtx,
- basePathState,
- );
- if (fallbackRewrite) {
+ for (const rewrite of configRewrites.fallback) {
+ const fallbackRewrite = matchRewrite(
+ matchResolvedPathname(resolvedPathname),
+ [rewrite],
+ rewriteRequestContext(),
+ basePathState,
+ );
+ if (!fallbackRewrite) continue;
if (isExternalUrl(fallbackRewrite)) {
// Bare proxy — no middleware-header merge (see Step 8 asymmetry note).
return {
@@ -584,13 +593,11 @@ export async function runPagesRequest(
response: await proxyExternal(request, fallbackRewrite),
};
}
- response = await deps.renderPage(
- request,
- mergeRewriteQuery(resolvedUrl, fallbackRewrite),
- undefined,
- stagedHeaders,
- );
+ resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
+ resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
+ response = await deps.renderPage(request, resolvedUrl, undefined, stagedHeaders);
matchedFallbackRewrite = true;
+ if (response.status !== 404) break;
}
}
@@ -622,18 +629,21 @@ export async function runPagesRequest(
: null
: pageMatch;
if (!devPageMatch && configRewrites.fallback?.length) {
- const fallbackRewrite = matchRewrite(
- matchResolvedPathname(resolvedPathname),
- configRewrites.fallback,
- postMwReqCtx,
- basePathState,
- );
- if (fallbackRewrite) {
+ for (const rewrite of configRewrites.fallback) {
+ const fallbackRewrite = matchRewrite(
+ matchResolvedPathname(resolvedPathname),
+ [rewrite],
+ rewriteRequestContext(),
+ basePathState,
+ );
+ if (!fallbackRewrite) continue;
if (isExternalUrl(fallbackRewrite)) {
// Bare proxy — no middleware-header merge (see Step 8 asymmetry note).
return { type: "response", response: await proxyExternal(request, fallbackRewrite) };
}
resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite);
+ resolvedPathname = pathnameForResolvedUrl(resolvedUrl);
+ if (deps.matchPageRoute?.(resolvedPathname, request)) break;
}
}
refreshDataRewriteHeader();
diff --git a/packages/vinext/src/shims/internal/app-route-detection.ts b/packages/vinext/src/shims/internal/app-route-detection.ts
index 20c9fc024..ac0fe0f5b 100644
--- a/packages/vinext/src/shims/internal/app-route-detection.ts
+++ b/packages/vinext/src/shims/internal/app-route-detection.ts
@@ -35,11 +35,9 @@
* Issue: https://github.com/cloudflare/vinext/issues/1526
*/
import type { VinextLinkPrefetchRoute } from "../../client/vinext-next-data.js";
-import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js";
import { stripBasePath, removeTrailingSlash } from "../../utils/base-path.js";
import { getLocalePathPrefix } from "../../utils/domain-locale.js";
-
-const appRouteTrieCache = createRouteTrieCache();
+import { resolveHybridClientRouteOwner } from "./hybrid-client-route-owner.js";
declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
@@ -100,22 +98,6 @@ function resolveSameOriginPathname(href: string, basePath: string): string | nul
return pathname.length === localePrefixLength ? "/" : pathname.slice(localePrefixLength);
}
-/**
- * Returns true when the prefetch href matches any route in the App Router
- * prefetch manifest (static or dynamic). Returns false when the manifest is
- * absent (Pages-Router-only build), the URL is external, or no route matches.
- */
-export function matchesAppRoute(href: string, basePath: string): boolean {
- if (typeof window === "undefined") return false;
- const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
- if (!routes || routes.length === 0) return false;
-
- const pathname = resolveSameOriginPathname(href, basePath);
- if (pathname === null) return false;
-
- return matchRouteWithTrie(pathname, routes, appRouteTrieCache) !== null;
-}
-
/**
* Record `components[pathname] = { __appRouter: true }` on the shared
* Pages Router map when the href matches an App Router route. No-op when the
@@ -130,7 +112,7 @@ export function matchesAppRoute(href: string, basePath: string): boolean {
*/
export function markAppRouteDetectedOnPrefetch(href: string, basePath: string): void {
if (typeof window === "undefined") return;
- if (!matchesAppRoute(href, basePath)) return;
+ if (resolveHybridClientRouteOwner(href, basePath) !== "app") return;
const rawPathname = resolveSameOriginPathname(href, basePath);
if (rawPathname === null) return;
diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts
new file mode 100644
index 000000000..fa1b8a4f2
--- /dev/null
+++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts
@@ -0,0 +1,215 @@
+/**
+ * Client-side resolver that decides whether a URL should be soft-navigated
+ * (App Router / RSC) or hard-navigated (Pages Router / document). Delegates
+ * the owner decision to `compareHybridRoutePatterns` in `routing/utils.ts`
+ * so the server and the client reach the same answer for the same
+ * (pages pattern, app pattern) pair.
+ *
+ * Lives in `shims/internal/` because both `link.tsx` and the App Router
+ * browser entry import it without pulling in the server route graph.
+ *
+ * The App + Pages route manifests are emitted once per page load by the
+ * Vite plugin onto the matching `__VINEXT_*_PREFETCH_ROUTES__` window
+ * globals (see `entries/app-browser-entry.ts` and
+ * `entries/pages-client-entry.ts`). Hybrid builds expose both globals; a
+ * single-router build only sets its own.
+ */
+import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js";
+import { compareHybridRoutePatterns } from "../../routing/utils.js";
+import {
+ isExternalUrl,
+ matchRewrite,
+ parseCookies,
+ type RequestContext,
+} from "../../config/config-matchers.js";
+import type { NextRewrite } from "../../config/next-config.js";
+import { stripBasePath } from "../../utils/base-path.js";
+import { getLocalePathPrefix } from "../../utils/domain-locale.js";
+import { mergeRewriteQuery } from "../../utils/query.js";
+import type {
+ VinextLinkPrefetchRoute,
+ VinextPagesLinkPrefetchRoute,
+} from "../../client/vinext-next-data.js";
+
+export type HybridClientOwner = "app" | "document" | "pages";
+
+declare global {
+ // oxlint-disable-next-line typescript-eslint/consistent-type-definitions
+ interface Window {
+ __VINEXT_LINK_PREFETCH_ROUTES__?: VinextLinkPrefetchRoute[];
+ __VINEXT_PAGES_LINK_PREFETCH_ROUTES__?: VinextPagesLinkPrefetchRoute[];
+ __VINEXT_CLIENT_REWRITES__?: {
+ afterFiles: NextRewrite[];
+ beforeFiles: NextRewrite[];
+ fallback: NextRewrite[];
+ };
+ }
+}
+
+function resolveClientRewrite(
+ href: string,
+ basePath: string,
+ rewrites: readonly NextRewrite[],
+ continueAfterMatch = false,
+): { kind: "document" } | { href: string; kind: "rewrite" } | null {
+ const initialUrl = new URL(href, window.location.href);
+ const basePathState = {
+ basePath,
+ hadBasePath: basePath
+ ? initialUrl.pathname === basePath || initialUrl.pathname.startsWith(`${basePath}/`)
+ : true,
+ };
+ let currentHref = href;
+ let matched = false;
+
+ for (const rewrite of rewrites) {
+ const pathname = resolveSameOriginPathname(currentHref, basePath);
+ if (pathname === null) return null;
+ const url = new URL(currentHref, window.location.href);
+ const headers = new Headers({ "user-agent": globalThis.navigator?.userAgent ?? "" });
+ const context: RequestContext = {
+ cookies: parseCookies(globalThis.document?.cookie ?? ""),
+ headers,
+ host: url.hostname,
+ query: url.searchParams,
+ };
+ const rewritten = matchRewrite(pathname, [rewrite], context, basePathState);
+ if (rewritten === null) continue;
+ if (isExternalUrl(rewritten)) return { kind: "document" };
+ currentHref = mergeRewriteQuery(currentHref, rewritten);
+ matched = true;
+ if (!continueAfterMatch) break;
+ }
+
+ return matched ? { href: currentHref, kind: "rewrite" } : null;
+}
+
+const appRouteTrieCache = createRouteTrieCache();
+const pagesRouteTrieCache = createRouteTrieCache();
+
+/**
+ * Build a `/`-joined pattern from a manifest's `patternParts`. Mirrors the
+ * server-side route-graph shape (`{ pattern: string }`) so the
+ * `compareHybridRoutePatterns` segment-rank comparator can score both Pages
+ * and App patterns. The
+ * `patternParts` array never includes an empty string for the static `/`
+ * route (the App catch-all handles the bare path), so the simple join is
+ * safe for everything the route trie actually matches.
+ */
+function patternFromParts(parts: readonly string[]): string {
+ return "/" + parts.join("/");
+}
+
+function resolveSameOriginPathname(href: string, basePath: string): string | null {
+ if (typeof window === "undefined") return null;
+ let url: URL;
+ try {
+ url = new URL(href, window.location.href);
+ } catch {
+ return null;
+ }
+ if (url.origin !== window.location.origin) return null;
+ const pathname = stripBasePath(url.pathname, basePath);
+ const locale = getLocalePathPrefix(pathname, window.__VINEXT_LOCALES__);
+ if (!locale) return pathname;
+ const localePrefixLength = locale.length + 1;
+ return pathname.length === localePrefixLength ? "/" : pathname.slice(localePrefixLength);
+}
+
+function matchAppRoute(
+ href: string,
+ basePath: string,
+ routes: readonly VinextLinkPrefetchRoute[],
+): VinextLinkPrefetchRoute | null {
+ const pathname = resolveSameOriginPathname(href, basePath);
+ if (pathname === null) return null;
+ return (
+ matchRouteWithTrie(pathname, routes as VinextLinkPrefetchRoute[], appRouteTrieCache)?.route ??
+ null
+ );
+}
+
+function matchPagesRoute(
+ href: string,
+ basePath: string,
+ routes: readonly VinextPagesLinkPrefetchRoute[],
+): VinextPagesLinkPrefetchRoute | null {
+ const pathname = resolveSameOriginPathname(href, basePath);
+ if (pathname === null) return null;
+ return (
+ matchRouteWithTrie(pathname, routes as VinextPagesLinkPrefetchRoute[], pagesRouteTrieCache)
+ ?.route ?? null
+ );
+}
+
+/**
+ * Decide which router should own a soft-navigated URL. Returns:
+ * - "app" → the App Router runtime handles the navigation (RSC fetch).
+ * - "pages" → Pages owns the URL; the caller must hard-navigate instead.
+ * - null → no router matched (preserves the existing 404 path).
+ *
+ * `basePath` must match what the page uses (typically `process.env.__NEXT_ROUTER_BASEPATH`).
+ *
+ * The lookup uses the App and Pages manifests on `window` so the same
+ * matcher trie produces the same result the server will see when the
+ * request lands.
+ */
+export function resolveHybridClientRouteOwner(
+ href: string,
+ basePath: string,
+): HybridClientOwner | null {
+ if (typeof window === "undefined") return null;
+
+ const appRoutes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
+ const pagesRoutes = window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__;
+ const rewrites = window.__VINEXT_CLIENT_REWRITES__;
+
+ if (rewrites) {
+ const beforeFilesRewrite = resolveClientRewrite(href, basePath, rewrites.beforeFiles, true);
+ if (beforeFilesRewrite?.kind === "document") return "document";
+ if (beforeFilesRewrite?.kind === "rewrite") href = beforeFilesRewrite.href;
+ }
+
+ let appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null;
+ let pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null;
+
+ if (
+ rewrites &&
+ (appMatch === null || appMatch.isDynamic) &&
+ (pagesMatch === null || pagesMatch.isDynamic)
+ ) {
+ for (const rewrite of rewrites.afterFiles) {
+ const afterFilesRewrite = resolveClientRewrite(href, basePath, [rewrite]);
+ if (afterFilesRewrite?.kind === "document") return "document";
+ if (afterFilesRewrite?.kind !== "rewrite") continue;
+ href = afterFilesRewrite.href;
+ appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null;
+ pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null;
+ if (appMatch || pagesMatch) break;
+ }
+ }
+
+ if (rewrites && appMatch === null && pagesMatch === null) {
+ for (const rewrite of rewrites.fallback) {
+ const fallbackRewrite = resolveClientRewrite(href, basePath, [rewrite]);
+ if (fallbackRewrite?.kind === "document") return "document";
+ if (fallbackRewrite?.kind !== "rewrite") continue;
+ href = fallbackRewrite.href;
+ appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null;
+ pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null;
+ if (appMatch || pagesMatch) break;
+ }
+ }
+
+ if (appMatch === null && pagesMatch === null) return null;
+ if (pagesMatch === null) return appMatch!.documentOnly ? "document" : "app";
+ if (appMatch === null) return pagesMatch.documentOnly ? "document" : "pages";
+ const owner = compareHybridRoutePatterns(
+ patternFromParts(pagesMatch.patternParts),
+ pagesMatch.isDynamic,
+ patternFromParts(appMatch.patternParts),
+ appMatch.isDynamic,
+ );
+ const winningRoute = owner === "app" ? appMatch : pagesMatch;
+ return winningRoute.documentOnly ? "document" : owner;
+}
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index cf60ac630..3008d3436 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -70,6 +70,7 @@ import {
resolvePagesDataNavigationTarget,
} from "./internal/pages-data-target.js";
import { markAppRouteDetectedOnPrefetch } from "./internal/app-route-detection.js";
+import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js";
import { getCurrentBrowserLocale } from "./client-locale.js";
import {
clearLinkForCurrentNavigation,
@@ -391,6 +392,17 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi
schedule(() => {
void (async () => {
if (hasAppNavigationRuntime()) {
+ // Hybrid ownership: skip the App RSC prefetch when Pages owns the
+ // URL. The App's `__VINEXT_LINK_PREFETCH_ROUTES__` may include an
+ // App catch-all that also matches the same path, so a naive
+ // prefetch would fetch an RSC stream for a Pages route — that
+ // stream is never consumed (the click path now hard-navigates to
+ // Pages) and would also race the request the browser will issue on
+ // the actual navigation.
+ const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath);
+ if (hybridOwner === "pages" || hybridOwner === "document") {
+ return;
+ }
const autoPrefetch =
mode === "auto"
? resolveAutoAppRoutePrefetch(prefetchHref)
@@ -977,6 +989,30 @@ const Link = forwardRef(function Link(
}
}
+ // Hybrid ownership check: when the App Router runtime is installed and
+ // the target URL is owned by the Pages Router, soft-navigating with RSC
+ // would either (a) hit the App catch-all, or (b) bounce off
+ // `renderPagesFallback` returning null for RSC requests. Either way the
+ // user lands on the wrong route. Pages only renders HTML documents or
+ // `_next/data` JSON, so the only correct path is a document navigation.
+ //
+ // We compare ownership here, not in `navigateClientSide`, because the
+ // document navigation is committed synchronously by the browser — there
+ // is no RSC stream to suspend on, so the soft-navigation bookkeeping
+ // (`setPending`, `setLinkForCurrentNavigation`) would be a no-op at best
+ // and a stale `useLinkStatus` indicator at worst.
+ if (
+ getNavigationRuntime()?.functions.navigate &&
+ ["pages", "document"].includes(resolveHybridClientRouteOwner(navigateHref, __basePath) ?? "")
+ ) {
+ if (replace) {
+ window.location.replace(absoluteFullHref);
+ } else {
+ window.location.assign(absoluteFullHref);
+ }
+ return;
+ }
+
// App Router: delegate to navigateClientSide which handles scroll save,
// hash-only changes, RSC fetch, and two-phase URL commit.
if (getNavigationRuntime()?.functions.navigate) {
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 80a00995e..1847f9103 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -54,6 +54,7 @@ import { assertSafeNavigationUrl } from "./url-safety.js";
import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.js";
import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js";
import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js";
+import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js";
import { retryScrollTo, scrollToHashTarget } from "./hash-scroll.js";
import {
beginAppRouterScrollIntent,
@@ -1760,6 +1761,19 @@ function restoreScrollPosition(state: unknown): void {
}
}
+/**
+ * Hard-navigate to a URL via `window.location`, preserving push/replace
+ * semantics. Used for URLs the App Router cannot serve (Pages-owned
+ * targets in a hybrid build) and for catch-all RSC failures.
+ */
+function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void {
+ if (mode === "replace") {
+ window.location.replace(fullHref);
+ } else {
+ window.location.assign(fullHref);
+ }
+}
+
/**
* Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation.
*/
@@ -1788,17 +1802,34 @@ export async function navigateClientSide(
return;
}
- if (mode === "replace") {
- window.location.replace(href);
- } else {
- window.location.assign(href);
- }
+ hardNavigateTo(href, mode);
await new Promise(() => {});
return;
}
normalizedHref = localPath;
}
+ // Hybrid ownership: when both an App and a Pages route can match the
+ // destination, defer to the shared `compareHybridRoutePatterns` decision
+ // (the same logic the server uses for direct document loads). If Pages
+ // owns the URL, hard-navigate so the Pages handler renders the page
+ // instead of the App catch-all — soft-navigating through RSC would
+ // either return null (because `renderPagesFallback` short-circuits RSC
+ // requests) or render the App catch-all's path array. This is the
+ // programmatic equivalent of the link click / prefetch check in
+ // `link.tsx`.
+ const hybridOwner = resolveHybridClientRouteOwner(normalizedHref, __basePath);
+ if (hybridOwner === "pages" || hybridOwner === "document") {
+ const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath);
+ notifyAppRouterTransitionStart(fullHref, mode);
+ if (mode === "push") {
+ saveScrollPosition();
+ }
+ hardNavigateTo(fullHref, mode);
+ await new Promise(() => {});
+ return;
+ }
+
const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath);
stageAppNavigationFailureTarget(fullHref);
// Match Next.js: App Router reports navigation start before dispatching,
@@ -1841,11 +1872,7 @@ export async function navigateClientSide(
return;
}
- if (mode === "replace") {
- window.location.replace(fullHref);
- } else {
- window.location.assign(fullHref);
- }
+ hardNavigateTo(fullHref, mode);
await new Promise(() => {});
return;
}
@@ -2027,6 +2054,17 @@ const _appRouter: AppRouterInstance = {
prefetchHref = localPath;
}
+ // Hybrid ownership: when a Pages route owns the URL, the App Router
+ // cannot serve it (Pages produces HTML documents / `_next/data` JSON,
+ // not RSC streams). Prefetching an RSC URL would either 404 or warm
+ // an unusable cache entry. The matching `push`/`replace` call will
+ // hard-navigate via `window.location`, so a no-op here is correct —
+ // the document prefetch the link shim emits on hover still runs.
+ const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath);
+ if (hybridOwner === "pages" || hybridOwner === "document") {
+ return;
+ }
+
// Prefetch the RSC payload for the target route and store in cache.
// We must add to prefetchedUrls manually for deduplication.
// prefetchRscResponse only manages the cache Map, not the URL set.
diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts
index 3c8f5530e..9ada50ab7 100644
--- a/packages/vinext/src/shims/router.ts
+++ b/packages/vinext/src/shims/router.ts
@@ -36,8 +36,8 @@ import { buildPagesDataHref } from "./internal/pages-data-url.js";
import {
getPagesRouterComponentsMap,
markAppRouteDetectedOnPrefetch,
- matchesAppRoute,
} from "./internal/app-route-detection.js";
+import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js";
import { dedupedPagesDataFetch } from "./internal/pages-data-fetch-dedup.js";
import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js";
import { isUnknownRecord } from "../utils/record.js";
@@ -2298,7 +2298,7 @@ async function performNavigation(
appPathNorm !== null ? getPagesRouterComponentsMap()[appPathNorm] : undefined;
const appRouteDetected =
(appPathEntry !== undefined && "__appRouter" in appPathEntry && appPathEntry.__appRouter) ||
- matchesAppRoute(resolved, __basePath);
+ ["app", "document"].includes(resolveHybridClientRouteOwner(resolved, __basePath) ?? "");
if (appRouteDetected) {
if (mode === "push") window.location.assign(full);
else window.location.replace(full);
diff --git a/playwright.config.ts b/playwright.config.ts
index b9e1f7549..3cdded984 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -227,6 +227,18 @@ const projectServers = {
timeout: 60_000,
},
},
+ "use-params-app-pages": {
+ testDir: "./tests/e2e/use-params-app-pages",
+ use: { baseURL: "http://localhost:4186" },
+ server: {
+ command:
+ "cd ../../.. && npx vp run vinext#build && cd tests/fixtures/use-params-app-pages && node ../../../packages/vinext/dist/cli.js build && node ../../../packages/vinext/dist/cli.js start --port 4186",
+ cwd: "./tests/fixtures/use-params-app-pages",
+ port: 4186,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ },
"ppr-impact-demo": {
testDir: "./tests/e2e/ppr-impact-demo",
use: { baseURL: "http://localhost:4187" },
@@ -241,12 +253,12 @@ const projectServers = {
},
"app-front-redirect-issue": {
testDir: "./tests/e2e/app-front-redirect-issue",
- use: { baseURL: "http://localhost:4186" },
+ use: { baseURL: "http://localhost:4188" },
server: {
command:
- "(test -e node_modules || test -L node_modules || ln -s ../../../fixtures/app-basic/node_modules node_modules) && npx vp run vinext#build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js start --port 4186",
+ "(test -e node_modules || test -L node_modules || ln -s ../../../fixtures/app-basic/node_modules node_modules) && npx vp run vinext#build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js start --port 4188",
cwd: "./tests/e2e/app-front-redirect-issue/fixture",
- port: 4186,
+ port: 4188,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 99305a6fe..fdbf672e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1586,6 +1586,31 @@ importers:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.24
version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)'
+ tests/fixtures/use-params-app-pages:
+ dependencies:
+ '@vitejs/plugin-rsc':
+ specifier: 'catalog:'
+ version: 0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ react:
+ specifier: 'catalog:'
+ version: 19.2.7
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.7(react@19.2.7)
+ react-server-dom-webpack:
+ specifier: 'catalog:'
+ version: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ vinext:
+ specifier: workspace:*
+ version: link:../../../packages/vinext
+ vite:
+ specifier: npm:@voidzero-dev/vite-plus-core@0.1.24
+ version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)'
+ devDependencies:
+ vite-plus:
+ specifier: 'catalog:'
+ version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.6(@voidzero-dev/vite-plus-test@0.1.24))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)
+
packages:
'@alloc/quick-lru@5.2.0':
diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts
index 562d2f421..751a89c6e 100644
--- a/tests/app-pages-bridge.test.ts
+++ b/tests/app-pages-bridge.test.ts
@@ -49,6 +49,25 @@ describe("renderPagesFallback", () => {
expect(loadPagesEntry).not.toHaveBeenCalled();
});
+ it("allows middleware-rewritten RSC requests to return a Pages document", async () => {
+ const renderPage = vi.fn(
+ () => new Response("pages", { headers: { "content-type": "text/html" } }),
+ );
+ const response = await renderPagesFallback(
+ {
+ allowRscDocumentFallback: true,
+ isRscRequest: true,
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ pathname: "/pages",
+ request: new Request("http://localhost/source"),
+ url: new URL("http://localhost/source"),
+ },
+ { ...defaultDeps, loadPagesEntry: () => ({ renderPage }) },
+ );
+
+ expect(await response!.text()).toBe("pages");
+ });
+
it("rebuilds request when middleware request headers are present", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api"));
const deps = {
@@ -199,6 +218,143 @@ describe("renderPagesFallback", () => {
expect(await res!.text()).toBe("page-response");
});
+ it("filters static and dynamic Pages matches by ownership phase", async () => {
+ const renderPage = vi.fn(() => new Response("page"));
+ const request = new Request("http://localhost/blog/hello");
+ const url = new URL(request.url);
+ const deps = {
+ ...defaultDeps,
+ loadPagesEntry: () => ({
+ matchPageRoute: () => ({ route: { isDynamic: true, pattern: "/blog/:slug" } }),
+ renderPage,
+ }),
+ };
+
+ expect(
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ matchKind: "static",
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ request,
+ url,
+ },
+ deps,
+ ),
+ ).toBeNull();
+ expect(
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ matchKind: "dynamic",
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ request,
+ url,
+ },
+ deps,
+ ),
+ ).not.toBeNull();
+ });
+
+ it("filters static and dynamic Pages API matches by ownership phase", async () => {
+ const handleApiRoute = vi.fn(() => new Response("api"));
+ const request = new Request("http://localhost/api/posts/hello");
+ const url = new URL(request.url);
+ const deps = {
+ ...defaultDeps,
+ loadPagesEntry: () => ({
+ handleApiRoute,
+ matchApiRoute: () => ({ route: { isDynamic: true, pattern: "/api/posts/:slug" } }),
+ }),
+ };
+
+ expect(
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ matchKind: "static",
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ request,
+ url,
+ },
+ deps,
+ ),
+ ).toBeNull();
+ expect(handleApiRoute).not.toHaveBeenCalled();
+
+ expect(
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ matchKind: "dynamic",
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ request,
+ url,
+ },
+ deps,
+ ),
+ ).not.toBeNull();
+ expect(handleApiRoute).toHaveBeenCalledTimes(1);
+ });
+
+ it("decodes rewritten page paths without decoding their query", async () => {
+ const matchPageRoute = vi.fn(() => ({
+ route: { isDynamic: false, pattern: "/café" },
+ }));
+ const renderPage = vi.fn(() => new Response("page"));
+ const request = new Request("http://localhost/legacy?original=1");
+ const url = new URL(request.url);
+
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ pathname: "/caf%C3%A9?value=hello%20world",
+ request,
+ url,
+ },
+ {
+ ...defaultDeps,
+ loadPagesEntry: () => ({ matchPageRoute, renderPage }),
+ },
+ );
+
+ expect(matchPageRoute).toHaveBeenCalledWith("/café?value=hello%20world", request);
+ expect(renderPage).toHaveBeenCalledWith(
+ request,
+ "/café?value=hello%20world",
+ {},
+ undefined,
+ null,
+ );
+ });
+
+ it("decodes rewritten API paths without decoding their query", async () => {
+ const matchApiRoute = vi.fn(() => ({
+ route: { isDynamic: false, pattern: "/api/café" },
+ }));
+ const handleApiRoute = vi.fn(() => new Response("api"));
+ const request = new Request("http://localhost/api/legacy?original=1");
+ const url = new URL(request.url);
+
+ await renderPagesFallback(
+ {
+ isRscRequest: false,
+ middlewareContext: { headers: null, requestHeaders: null, status: null },
+ pathname: "/api/caf%C3%A9?value=hello%20world",
+ request,
+ url,
+ },
+ {
+ ...defaultDeps,
+ loadPagesEntry: () => ({ handleApiRoute, matchApiRoute }),
+ },
+ );
+
+ expect(matchApiRoute).toHaveBeenCalledWith("/api/café?value=hello%20world", request);
+ expect(handleApiRoute).toHaveBeenCalledWith(request, "/api/café?value=hello%20world");
+ });
+
it("appends the middleware draft cookie to an API fallback response (#1520)", async () => {
const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response"));
const deps = {
diff --git a/tests/app-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts
index cebeeddb6..62a151b7c 100644
--- a/tests/app-router-next-config-codegen.test.ts
+++ b/tests/app-router-next-config-codegen.test.ts
@@ -144,6 +144,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
expect(code).toContain("renderPagesFallback as __renderPagesFallback");
expect(code).toContain("server/app-pages-bridge.js");
expect(code).toContain("return __renderPagesFallback(");
+ expect(code).toContain(
+ "{ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }",
+ );
expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");');
expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse");
expect(code).toContain(
@@ -170,9 +173,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => {
// dispatcher as well as page rendering.
const code = generateSsrEntry(true);
- expect(code).toContain(
- 'export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";',
- );
+ expect(code).toContain("handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage");
});
it("embeds basePath and trailingSlash alongside config", () => {
diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts
index f60f45323..c2211a363 100644
--- a/tests/app-rsc-handler.test.ts
+++ b/tests/app-rsc-handler.test.ts
@@ -592,6 +592,95 @@ describe("createAppRscHandler", () => {
expect(dispatchMatchedPage).not.toHaveBeenCalled();
});
+ it("propagates middleware rewrite query parameters to App pages", async () => {
+ let pageOptions: Parameters[0] | undefined;
+ const handler = createHandler({
+ configHeaders: [],
+ dispatchMatchedPage: async (options) => {
+ pageOptions = options;
+ return new Response("page");
+ },
+ middlewareModule: {
+ default: () =>
+ new Response(null, {
+ headers: {
+ "x-middleware-rewrite": "https://example.test/docs/about?destination=2&same=new",
+ },
+ }),
+ },
+ });
+
+ await handler(new Request("https://example.test/docs/source?original=1&same=old"), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ destination: "2",
+ same: "new",
+ });
+ });
+
+ it("evaluates config rewrite conditions against middleware rewrite queries", async () => {
+ let pageOptions: Parameters[0] | undefined;
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [
+ {
+ source: "/intermediate",
+ destination: "/about?destination=2",
+ has: [{ type: "query", key: "stage", value: "1" }],
+ },
+ ],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedPage: async (options) => {
+ pageOptions = options;
+ return new Response("page");
+ },
+ middlewareModule: {
+ default: () =>
+ new Response(null, {
+ headers: {
+ "x-middleware-rewrite": "https://example.test/docs/intermediate?stage=1",
+ },
+ }),
+ },
+ });
+
+ await handler(new Request("https://example.test/docs/source"), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ destination: "2",
+ stage: "1",
+ });
+ });
+
+ it("allows middleware-rewritten RSC requests to hand off to Pages HTML", async () => {
+ const headers = createRscRequestHeaders();
+ const rscUrl = await createRscRequestUrl("/docs/source", headers);
+ const renderPagesFallback = vi.fn(async ({ allowRscDocumentFallback, pathname }) =>
+ allowRscDocumentFallback && pathname === "/pages"
+ ? new Response("pages", { headers: { "content-type": "text/html" } })
+ : null,
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ matchRoute: () => null,
+ middlewareModule: {
+ default: () =>
+ new Response(null, {
+ headers: { "x-middleware-rewrite": "https://example.test/docs/pages" },
+ }),
+ },
+ renderPagesFallback,
+ });
+
+ const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null);
+
+ expect(response.headers.get("content-type")).toBe("text/html");
+ expect(await response.text()).toBe("pages");
+ });
+
it("does not duplicate additive config headers on non-redirect middleware responses", async () => {
const handler = createHandler({
configHeaders: [
@@ -1008,6 +1097,31 @@ describe("createAppRscHandler", () => {
expect(context?.searchParams.has("_rsc")).toBe(false);
});
+ it("preserves beforeFiles destination query while stripping the RSC cache key", async () => {
+ const headers = createRscRequestHeaders();
+ const rscUrl = await createRscRequestUrl("/docs/legacy?original=1", headers);
+ let pageOptions: Parameters[0] | undefined;
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [{ source: "/legacy", destination: "/about?destination=2" }],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedPage: async (options) => {
+ pageOptions = options;
+ return new Response("page");
+ },
+ });
+
+ await handler(new Request(`https://example.test${rscUrl}`, { headers }), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ });
+ });
+
it("runs beforeFiles rewrites before route matching", async () => {
const matchRoute = vi.fn((pathname: string) =>
pathname === "/about"
@@ -1017,7 +1131,10 @@ describe("createAppRscHandler", () => {
}
: null,
);
- const dispatchMatchedPage = vi.fn(async () => new Response("rewritten", { status: 200 }));
+ const dispatchMatchedPage = vi.fn(
+ async (_options: Parameters[0]) =>
+ new Response("rewritten", { status: 200 }),
+ );
const handler = createHandler({
configHeaders: [],
configRewrites: {
@@ -1039,6 +1156,171 @@ describe("createAppRscHandler", () => {
);
});
+ it("propagates rewritten query parameters to App pages", async () => {
+ const setNavigationContext = vi.fn();
+ let pageOptions: Parameters[0] | undefined;
+ const dispatchMatchedPage = vi.fn(
+ async (options: Parameters[0]) => {
+ pageOptions = options;
+ return new Response("rewritten", { status: 200 });
+ },
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [{ source: "/legacy", destination: "/about?destination=2&same=new" }],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedPage,
+ setNavigationContext,
+ });
+
+ await handler(new Request("https://example.test/docs/legacy?original=1&same=old"), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ same: "new",
+ });
+ expect(Object.fromEntries(setNavigationContext.mock.lastCall![0].searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ same: "new",
+ });
+ });
+
+ it("applies sequential beforeFiles rewrites with accumulated query conditions", async () => {
+ let pageOptions: Parameters[0] | undefined;
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [
+ { source: "/source", destination: "/intermediate?preview=1" },
+ {
+ source: "/intermediate",
+ destination: "/about?destination=2",
+ has: [{ type: "query", key: "preview", value: "1" }],
+ },
+ ],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedPage: async (options) => {
+ pageOptions = options;
+ return new Response("page");
+ },
+ });
+
+ await handler(new Request("https://example.test/docs/source?original=1"), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ preview: "1",
+ });
+ });
+
+ it("exposes unused rewrite source params through App searchParams", async () => {
+ let pageOptions: Parameters[0] | undefined;
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [
+ {
+ source: "/source/:section/:name",
+ destination: "/about?first=:section&second=:name",
+ },
+ ],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedPage: async (options) => {
+ pageOptions = options;
+ return new Response("page");
+ },
+ });
+
+ await handler(new Request("https://example.test/docs/source/hello/world"), null);
+
+ expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({
+ first: "hello",
+ name: "world",
+ second: "world",
+ section: "hello",
+ });
+ });
+
+ it.each(["afterFiles", "fallback"] as const)(
+ "continues through unmatched %s rewrite destinations",
+ async (rewritePhase) => {
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [],
+ afterFiles:
+ rewritePhase === "afterFiles"
+ ? [
+ { source: "/source", destination: "/intermediate" },
+ { source: "/intermediate", destination: "/about" },
+ ]
+ : [],
+ fallback:
+ rewritePhase === "fallback"
+ ? [
+ { source: "/source", destination: "/intermediate" },
+ { source: "/intermediate", destination: "/about" },
+ ]
+ : [],
+ },
+ matchRoute: (pathname) =>
+ pathname === "/about" ? { params: {}, route: createPageRoute() } : null,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/source"), null);
+
+ expect(response.status).toBe(200);
+ },
+ );
+
+ it("propagates rewritten query parameters to App route handlers", async () => {
+ const route = createPageRoute({
+ page: null,
+ pattern: "/api/static",
+ routeHandler: { GET: () => new Response("route") },
+ routeSegments: ["api", "static"],
+ });
+ const dispatchMatchedRouteHandler = vi.fn(
+ async (_options: Parameters[0]) =>
+ new Response("route"),
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [{ source: "/legacy", destination: "/api/static?destination=2&same=new" }],
+ afterFiles: [],
+ fallback: [],
+ },
+ dispatchMatchedRouteHandler,
+ matchRoute: (pathname) => (pathname === "/api/static" ? { params: {}, route } : null),
+ });
+
+ await handler(new Request("https://example.test/docs/legacy?original=1&same=old"), null);
+
+ const routeHandlerOptions = dispatchMatchedRouteHandler.mock.lastCall?.[0];
+ expect(Object.fromEntries(routeHandlerOptions!.searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ same: "new",
+ });
+ expect(new URL(routeHandlerOptions!.request.url).pathname).toBe("/docs/legacy");
+ expect(Object.fromEntries(new URL(routeHandlerOptions!.request.url).searchParams)).toEqual({
+ destination: "2",
+ original: "1",
+ same: "new",
+ });
+ });
+
it("does not let afterFiles rewrites override non-dynamic app routes", async () => {
const routes = {
"/about": createPageRoute({ pattern: "/about", routeSegments: ["about"] }),
@@ -1112,6 +1394,215 @@ describe("createAppRscHandler", () => {
);
});
+ it("lets a static Pages route win before afterFiles rewrites", async () => {
+ const dynamicRoute = createPageRoute({
+ isDynamic: true,
+ pattern: "/:path+",
+ routeSegments: ["[...path]"],
+ });
+ const renderPagesFallback = vi.fn(async () => new Response("pages:/about", { status: 200 }));
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [],
+ afterFiles: [{ source: "/about", destination: "/rewritten" }],
+ fallback: [],
+ },
+ matchRoute: () => ({ params: { path: ["about"] }, route: dynamicRoute }),
+ renderPagesFallback,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/about"), null);
+
+ expect(await response.text()).toBe("pages:/about");
+ expect(renderPagesFallback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ matchKind: "static",
+ pathname: "/about",
+ appRouteMatch: expect.objectContaining({ route: dynamicRoute }),
+ }),
+ );
+ });
+
+ it("runs afterFiles rewrites before dynamic Pages route ownership", async () => {
+ const appDynamicRoute = createPageRoute({
+ isDynamic: true,
+ pattern: "/:slug",
+ routeSegments: ["[slug]"],
+ });
+ const appDestinationRoute = createPageRoute({
+ pattern: "/destination",
+ routeSegments: ["destination"],
+ });
+ const renderPagesFallback = vi.fn(async ({ matchKind }) =>
+ matchKind === "dynamic" ? new Response("pages-dynamic", { status: 200 }) : null,
+ );
+ const dispatchMatchedPage = vi.fn(
+ async ({ route }) => new Response(`app:${route.pattern}`, { status: 200 }),
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [],
+ afterFiles: [{ source: "/legacy", destination: "/destination" }],
+ fallback: [],
+ },
+ dispatchMatchedPage,
+ matchRoute: (pathname): ReturnType => {
+ if (pathname === "/legacy") {
+ return { params: { slug: "legacy" }, route: appDynamicRoute };
+ }
+ if (pathname === "/destination") return { params: {}, route: appDestinationRoute };
+ return null;
+ },
+ renderPagesFallback,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/legacy"), null);
+
+ expect(await response.text()).toBe("app:/destination");
+ expect(renderPagesFallback).toHaveBeenCalledWith(
+ expect.objectContaining({ matchKind: "static", pathname: "/legacy" }),
+ );
+ expect(renderPagesFallback).not.toHaveBeenCalledWith(
+ expect.objectContaining({ matchKind: "dynamic", pathname: "/legacy" }),
+ );
+ });
+
+ it("rechecks static Pages routes after an afterFiles rewrite", async () => {
+ const renderPagesFallback = vi.fn(async ({ matchKind, pathname }) =>
+ matchKind === "static" && pathname === "/pages-static"
+ ? new Response("pages-static", { status: 200 })
+ : null,
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [],
+ afterFiles: [{ source: "/legacy", destination: "/pages-static" }],
+ fallback: [],
+ },
+ matchRoute: () => null,
+ renderPagesFallback,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/legacy"), null);
+
+ expect(await response.text()).toBe("pages-static");
+ });
+
+ it("rechecks static and dynamic Pages routes after a fallback rewrite", async () => {
+ const renderPagesFallback = vi.fn(async ({ matchKind, pathname }) =>
+ pathname === "/pages-dynamic" && matchKind === "dynamic"
+ ? new Response("pages-dynamic", { status: 200 })
+ : null,
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles: [],
+ afterFiles: [],
+ fallback: [{ source: "/legacy", destination: "/pages-dynamic" }],
+ },
+ matchRoute: () => null,
+ renderPagesFallback,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/legacy"), null);
+
+ expect(await response.text()).toBe("pages-dynamic");
+ expect(renderPagesFallback).toHaveBeenCalledWith(
+ expect.objectContaining({ matchKind: "static", pathname: "/pages-dynamic" }),
+ );
+ expect(renderPagesFallback).toHaveBeenCalledWith(
+ expect.objectContaining({ matchKind: "dynamic", pathname: "/pages-dynamic" }),
+ );
+ });
+
+ it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
+ "preserves and overrides query parameters for %s rewrites to Pages routes",
+ async (rewritePhase) => {
+ const renderPagesFallback = vi.fn(async ({ pathname }) =>
+ pathname.startsWith("/pages?") ? new Response("pages", { status: 200 }) : null,
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles:
+ rewritePhase === "beforeFiles"
+ ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }]
+ : [],
+ afterFiles:
+ rewritePhase === "afterFiles"
+ ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }]
+ : [],
+ fallback:
+ rewritePhase === "fallback"
+ ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }]
+ : [],
+ },
+ matchRoute: () => null,
+ renderPagesFallback,
+ });
+
+ const response = await handler(
+ new Request("https://example.test/docs/legacy?keep=1&same=old"),
+ null,
+ );
+
+ expect(await response.text()).toBe("pages");
+ const rewrittenCall = renderPagesFallback.mock.calls.find(([options]) =>
+ options.pathname.startsWith("/pages?"),
+ );
+ expect(rewrittenCall).toBeDefined();
+ const rewrittenUrl = new URL(rewrittenCall![0].pathname, "https://example.test");
+ expect(rewrittenUrl.pathname).toBe("/pages");
+ expect(Object.fromEntries(rewrittenUrl.searchParams)).toEqual({
+ dest: "2",
+ keep: "1",
+ same: "new",
+ });
+ },
+ );
+
+ it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
+ "excludes rewrite fragments from %s route matching",
+ async (rewritePhase) => {
+ const matchRoute = vi.fn((pathname: string) =>
+ pathname === "/about"
+ ? {
+ params: {},
+ route: createPageRoute(),
+ }
+ : null,
+ );
+ const handler = createHandler({
+ configHeaders: [],
+ configRewrites: {
+ beforeFiles:
+ rewritePhase === "beforeFiles"
+ ? [{ source: "/legacy/:code", destination: "/about#:code" }]
+ : [],
+ afterFiles:
+ rewritePhase === "afterFiles"
+ ? [{ source: "/legacy/:code", destination: "/about#:code" }]
+ : [],
+ fallback:
+ rewritePhase === "fallback"
+ ? [{ source: "/legacy/:code", destination: "/about#:code" }]
+ : [],
+ },
+ matchRoute,
+ });
+
+ const response = await handler(new Request("https://example.test/docs/legacy/500"), null);
+
+ expect(response.status).toBe(200);
+ expect(matchRoute).toHaveBeenCalledWith("/about");
+ expect(matchRoute).not.toHaveBeenCalledWith("/about#500");
+ },
+ );
+
it("serves public files before route matching and clears request context", async () => {
const clearRequestContext = vi.fn();
const matchRoute = vi.fn(() => null);
diff --git a/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts b/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts
index ac3f5b126..497eac586 100644
--- a/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts
+++ b/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
// Ported from Next.js: test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts
-const BASE = "http://localhost:4186";
+const BASE = "http://localhost:4188";
test.describe("app dir - front redirect issue", () => {
test("should redirect with a single bootstrap hydration", async ({ page }) => {
diff --git a/tests/e2e/cloudflare-dev/middleware.spec.ts b/tests/e2e/cloudflare-dev/middleware.spec.ts
index 93b757281..1df01557a 100644
--- a/tests/e2e/cloudflare-dev/middleware.spec.ts
+++ b/tests/e2e/cloudflare-dev/middleware.spec.ts
@@ -71,17 +71,12 @@ test.describe("middleware.ts with @cloudflare/vite-plugin", () => {
});
test("root route runs inside the Cloudflare Worker", async ({ request }) => {
- // / is matched by both pages/index.tsx (stub) and app/page.tsx (App Router).
// The connect handler must call next() so the Cloudflare plugin dispatches
// the request to app/page.tsx inside the Worker.
- //
- // "vinext on Cloudflare Workers" — app/page.tsx rendered in the Worker
- // "pages index" — pages stub rendered by the connect handler
const res = await request.get(`${BASE}/`);
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toContain("vinext on Cloudflare Workers");
- expect(body).not.toContain("pages index");
});
});
diff --git a/tests/e2e/cloudflare-dev/pages-router.spec.ts b/tests/e2e/cloudflare-dev/pages-router.spec.ts
index 9db83e081..55741fbcf 100644
--- a/tests/e2e/cloudflare-dev/pages-router.spec.ts
+++ b/tests/e2e/cloudflare-dev/pages-router.spec.ts
@@ -19,13 +19,9 @@
*
* ## How we assert "served by the Worker"
*
- * The app-router-cloudflare example has two competing handlers for /:
- * - pages/index.tsx → "
pages index
" (Pages Router stub)
- * - app/page.tsx → "vinext on Cloudflare Workers" (App Router, Worker)
- *
- * The Worker entry dispatches via the RSC entry, which serves app/page.tsx.
- * If the connect handler intercepted the request first, pages/index.tsx would
- * be rendered — without any Cloudflare runtime.
+ * The app-router-cloudflare example exposes a dedicated Pages route at
+ * /pages-index. If the host connect handler intercepted the request, it would
+ * render in Node instead of passing through the Cloudflare Worker entry.
*
* Note: Pages Router API routes on Cloudflare Workers are covered by the
* cloudflare-pages-router e2e suite (wrangler dev, not vite dev), which
@@ -37,17 +33,13 @@ import { test, expect } from "@playwright/test";
const BASE = "http://localhost:4178";
test.describe("Pages Router routes on Cloudflare Workers (vite dev)", () => {
- test("root route is served by the Worker, not intercepted by the connect handler", async ({
+ test("Pages route is served by the Worker, not intercepted by the connect handler", async ({
request,
}) => {
- // pages/index.tsx and app/page.tsx both match /.
- // The Worker entry dispatches via the RSC entry, which serves app/page.tsx.
- // If the connect handler intercepts first, pages/index.tsx is rendered instead.
- const res = await request.get(`${BASE}/`);
+ const res = await request.get(`${BASE}/pages-index`);
expect(res.status()).toBe(200);
const body = await res.text();
- expect(body).toContain("vinext on Cloudflare Workers");
- expect(body).not.toContain("pages index");
+ expect(body).toContain("pages index");
});
});
diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts
new file mode 100644
index 000000000..593e6a342
--- /dev/null
+++ b/tests/e2e/use-params-app-pages/use-params.spec.ts
@@ -0,0 +1,127 @@
+// Ported from Next.js: test/e2e/app-dir/use-params/use-params.test.ts
+// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts
+import { expect, test } from "@playwright/test";
+
+test.describe("use-params", () => {
+ test("should work for single dynamic param", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/a`);
+ await expect(page.locator("#param-id")).toHaveText("a");
+
+ // Also verify that only the [id] layout (not [id]/[id2]) drives this page
+ await expect(page.locator("#param-id2")).toHaveCount(0);
+ });
+
+ test("should work for nested dynamic params", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/a/b`);
+ await expect(page.locator("#param-id")).toHaveText("a");
+ await expect(page.locator("#param-id2")).toHaveText("b");
+ });
+
+ test("should work for catch all params", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/a/b/c/d/e/f/g`);
+ await expect(page.locator("#params")).toHaveText('["a","b","c","d","e","f","g"]');
+ });
+
+ test("should work for single dynamic param client navigating", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/`);
+ await page.locator("#to-a").click();
+ await expect(page.locator("#param-id")).toHaveText("a");
+ });
+
+ test("should work for nested dynamic params client navigating", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/`);
+ await page.locator("#to-a-b").click();
+ await expect(page.locator("#param-id")).toHaveText("a");
+ await expect(page.locator("#param-id2")).toHaveText("b");
+ });
+
+ test("should work on pages router", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/pages-dir/foobar`);
+ await expect(page.locator("#params")).toBeVisible();
+ await expect(page.locator("#params")).toHaveText('"foobar"');
+ });
+
+ test("Pages route wins for soft-navigation from an App Link", async ({ page, baseURL }) => {
+ // Hybrid invariant: the App root catch-all app/[...path] matches
+ // /pages-dir/foobar, but Pages dynamic route pages/pages-dir/[dynamic] has
+ // higher priority (Pages providers sort ahead of App providers in
+ // DefaultRouteMatcherManager). The client must not soft-navigate through
+ // the App runtime when Pages owns the URL — the App RSC stream would
+ // render the catch-all's path array. Falls back to a document navigation
+ // so the Pages handler renders the page.
+ await page.goto(`${baseURL}/`);
+ await page.locator("#to-pages").click();
+ await expect(page).toHaveURL(/\/pages-dir\/foobar$/);
+ await expect(page.locator("#params")).toHaveText('"foobar"');
+ });
+
+ test("Pages route wins for useRouter().push from an App page", async ({ page, baseURL }) => {
+ // Same hybrid invariant as the case, but driven through the
+ // App Router runtime boundary (next/navigation's useRouter). The
+ // ownership check now lives inside `navigateClientSide` so
+ // `router.push`, `router.replace`, and form-driven navigations all
+ // hard-navigate to the Pages document instead of sending an RSC
+ // request that the App catch-all would otherwise answer.
+ await page.goto(`${baseURL}/`);
+ await page.locator("#router-push-pages").click();
+ await expect(page).toHaveURL(/\/pages-dir\/foobar$/);
+ await expect(page.locator("#params")).toHaveText('"foobar"');
+ });
+
+ test("earlier static App segment wins on document load", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/account/details`);
+ await expect(page.locator("#route-owner")).toHaveText("app");
+ });
+
+ test("earlier static App segment wins for Link navigation", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/`);
+ await page.locator("#to-app-priority").click();
+ await expect(page).toHaveURL(/\/account\/details$/);
+ await expect(page.locator("#route-owner")).toHaveText("app");
+ });
+
+ test("earlier static App segment wins for router.push", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/`);
+ await page.locator("#router-push-app-priority").click();
+ await expect(page).toHaveURL(/\/account\/details$/);
+ await expect(page.locator("#route-owner")).toHaveText("app");
+ });
+
+ test("useRouter().prefetch does not issue an RSC request for a Pages-owned URL", async ({
+ page,
+ baseURL,
+ }) => {
+ // The hybrid check in `_appRouter.prefetch` short-circuits RSC URL
+ // construction for Pages-owned targets. Sending an RSC request would
+ // hit the App root catch-all's RSC handler and warm an unusable
+ // cache entry, so the prefetch should be a no-op.
+ await page.goto(`${baseURL}/`);
+
+ const rscRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.url().includes(".rsc")) {
+ rscRequests.push(req.url());
+ }
+ });
+
+ await page.locator("#router-prefetch-pages").click();
+
+ // Give the network layer a chance to flush anything the resolver
+ // accidentally started. The assertion is a strict zero RSC requests
+ // against /pages-dir/foobar — the only RSC traffic the page emits
+ // here is the bootstrap hydration (not targeted at the prefetch
+ // URL).
+ await page.waitForTimeout(250);
+ expect(
+ rscRequests.filter((u) => u.includes("/pages-dir/foobar")),
+ `unexpected RSC prefetch: ${rscRequests.join("\n")}`,
+ ).toEqual([]);
+ });
+
+ test("shouldn't rerender host component when prefetching", async ({ page, baseURL }) => {
+ await page.goto(`${baseURL}/rerenders/foobar`);
+ const initialRandom = await page.locator("#random").textContent();
+ await page.locator("a").hover();
+ await expect(page.locator("#random")).toHaveText(initialRandom ?? "");
+ });
+});
diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts
index 255a2dd20..7488eddce 100644
--- a/tests/entry-templates.test.ts
+++ b/tests/entry-templates.test.ts
@@ -123,6 +123,17 @@ const minimalAppRoutes: AppRoute[] = [
// ── App Router manifest construction ─────────────────────────────────
describe("App Router generated manifest construction", () => {
+ it("embeds client rewrite rules in the App browser entry", () => {
+ const code = generateBrowserEntry([], null, [], {
+ afterFiles: [],
+ beforeFiles: [{ source: "/legacy", destination: "/about" }],
+ fallback: [],
+ });
+
+ expect(code).toContain('window.__VINEXT_CLIENT_REWRITES__ = {"afterFiles":[],"beforeFiles"');
+ expect(code).toContain('"source":"/legacy","destination":"/about"');
+ });
+
it("embeds the Link auto-prefetch route manifest in the browser entry", () => {
const code = generateBrowserEntry([
...minimalAppRoutes,
diff --git a/tests/fixtures/cf-app-basic/pages/about.tsx b/tests/fixtures/cf-app-basic/pages/pages-about.tsx
similarity index 75%
rename from tests/fixtures/cf-app-basic/pages/about.tsx
rename to tests/fixtures/cf-app-basic/pages/pages-about.tsx
index fc96716e0..9990ade55 100644
--- a/tests/fixtures/cf-app-basic/pages/about.tsx
+++ b/tests/fixtures/cf-app-basic/pages/pages-about.tsx
@@ -4,7 +4,7 @@ export default function About() {
return (
About (Pages)
- Home
+ Home
);
}
diff --git a/tests/fixtures/cf-app-basic/pages/index.tsx b/tests/fixtures/cf-app-basic/pages/pages-home.tsx
similarity index 75%
rename from tests/fixtures/cf-app-basic/pages/index.tsx
rename to tests/fixtures/cf-app-basic/pages/pages-home.tsx
index a17614b11..1bf042a66 100644
--- a/tests/fixtures/cf-app-basic/pages/index.tsx
+++ b/tests/fixtures/cf-app-basic/pages/pages-home.tsx
@@ -4,7 +4,7 @@ export default function Home() {
return (