From 70668f65941e059b40d5528525a469a9f67745cc Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:33:41 +0100 Subject: [PATCH 1/6] fix(app-router): honor basepath opt-out rewrites --- packages/vinext/src/server/app-rsc-handler.ts | 45 +++++++++------- .../server/app-rsc-request-normalization.ts | 10 +++- tests/app-rsc-handler.test.ts | 54 +++++++++++++++++++ tests/app-rsc-request-normalization.test.ts | 8 +++ 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 18928210a8..714afb9e1f 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -482,7 +482,14 @@ async function handleAppRscRequest( if (originBlock) return originBlock; } - const normalized = normalizeRscRequest(request, options.basePath); + const hasBasePathOptOutRule = [ + ...options.configRedirects, + ...options.configRewrites.beforeFiles, + ...options.configRewrites.afterFiles, + ...options.configRewrites.fallback, + ...options.configHeaders, + ].some((rule) => rule.basePath === false); + const normalized = normalizeRscRequest(request, options.basePath, hasBasePathOptOutRule); if (normalized instanceof Response) return normalized; const { @@ -492,6 +499,7 @@ async function handleAppRscRequest( mountedSlotsHeader, renderMode, clientReuseManifest, + hadBasePath, } = normalized; let { pathname, cleanPathname } = normalized; let resolvedUrl = cleanPathname + url.search; @@ -505,13 +513,7 @@ async function handleAppRscRequest( // "should have the canonical url pathname on rewrite" const canonicalPathname = cleanPathname; - // The request reached this point so it was either under basePath (stripped - // by normalizeRscRequest) or basePath is empty. In both cases the matcher - // gating below treats default (basePath: true) rules as eligible. The App - // Router does not yet support `basePath: false` rules — they would need a - // pre-strip hook in normalizeRscRequest to fire. Tracked as follow-up to - // issue #1333. - const basePathState = { basePath: options.basePath, hadBasePath: true }; + const basePathState = { basePath: options.basePath, hadBasePath }; if ( pathname === VINEXT_PRERENDER_STATIC_PARAMS_PATH || @@ -619,6 +621,7 @@ async function handleAppRscRequest( const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers); const postMiddlewareRequestContext = buildPostMwRequestContext(userlandRequest); + let filesystemRouteEligible = hadBasePath || didMiddlewareRewrite; // Rewrites (beforeFiles, afterFiles, fallback) use `matchPathname` from // above to splice in the default locale before matching. Route matching @@ -646,10 +649,11 @@ async function handleAppRscRequest( if (beforeFilesRewrite) { resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + filesystemRouteEligible = true; } } - if (isImageOptimizationPath(cleanPathname)) { + if (filesystemRouteEligible && isImageOptimizationPath(cleanPathname)) { const imageRedirect = resolveDevImageRedirect( url, [ @@ -664,7 +668,7 @@ async function handleAppRscRequest( return Response.redirect(new URL(imageRedirect, url.origin).href, 302); } - if (options.handleMetadataRouteRequest) { + if (filesystemRouteEligible && options.handleMetadataRouteRequest) { const metadataRouteResponse = await options.handleMetadataRouteRequest(cleanPathname); if (metadataRouteResponse) { applyConfigHeadersToResponse(metadataRouteResponse.headers, { @@ -678,13 +682,15 @@ async function handleAppRscRequest( } } - const publicFileResponse = resolvePublicFileRoute({ - cleanPathname, - middlewareContext, - pathname, - publicFiles: options.publicFiles, - request, - }); + const publicFileResponse = filesystemRouteEligible + ? resolvePublicFileRoute({ + cleanPathname, + middlewareContext, + pathname, + publicFiles: options.publicFiles, + request, + }) + : null; if (publicFileResponse) { options.clearRequestContext(); return publicFileResponse; @@ -715,7 +721,7 @@ async function handleAppRscRequest( // page renders, so there is no stale-value risk for ordinary page renders. // For action requests we intentionally do not re-run rewrites — actions // are always processed against the cleanPathname they were posted to. - const preActionMatch = options.matchRoute(cleanPathname); + const preActionMatch = filesystemRouteEligible ? options.matchRoute(cleanPathname) : null; if (preActionMatch) { setRootParams(pickRootParams(preActionMatch.params, preActionMatch.route.rootParamNames)); } @@ -788,6 +794,7 @@ async function handleAppRscRequest( const renderPagesForMatchKind = async ( matchKind: "dynamic" | "static", ): Promise => { + if (!filesystemRouteEligible) return null; const response = match === null || match.route.isDynamic ? ((await options.renderPagesFallback?.({ @@ -839,6 +846,7 @@ async function handleAppRscRequest( if (!afterFilesRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + filesystemRouteEligible = true; match = options.matchRoute(cleanPathname); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { @@ -881,6 +889,7 @@ async function handleAppRscRequest( if (!fallbackRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); cleanPathname = pathnameForResolvedUrl(resolvedUrl); + filesystemRouteEligible = true; match = options.matchRoute(cleanPathname); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { diff --git a/packages/vinext/src/server/app-rsc-request-normalization.ts b/packages/vinext/src/server/app-rsc-request-normalization.ts index 1caa5e4b33..41afa36b77 100644 --- a/packages/vinext/src/server/app-rsc-request-normalization.ts +++ b/packages/vinext/src/server/app-rsc-request-normalization.ts @@ -42,6 +42,8 @@ export type NormalizedRscRequest = { renderMode: AppRscRenderMode; /** Parsed ClientReuseManifest hint. Verification and skip authorization happen later. */ clientReuseManifest: ClientReuseManifestParseResult; + /** Whether the incoming pathname included the configured basePath. */ + hadBasePath: boolean; }; /** @@ -77,6 +79,7 @@ export type NormalizedRscRequest = { export function normalizeRscRequest( request: Request, basePath: string, + allowOutsideBasePath = false, ): Response | NormalizedRscRequest { const url = new URL(request.url); @@ -98,16 +101,18 @@ export function normalizeRscRequest( // Step 4: Collapse double-slashes and resolve . / .. segments. let pathname = normalizePath(decoded); + let hadBasePath = true; // Step 5: basePath check and strip. // Skipped when basePath is empty (no basePath configured). // /__vinext/ prefix bypasses the check for internal prerender endpoints // that must be reachable regardless of basePath configuration. if (basePath) { - if (!hasBasePath(pathname, basePath) && !pathname.startsWith("/__vinext/")) { + hadBasePath = hasBasePath(pathname, basePath); + if (!hadBasePath && !pathname.startsWith("/__vinext/") && !allowOutsideBasePath) { return notFoundResponse(); } - pathname = stripBasePath(pathname, basePath); + if (hadBasePath) pathname = stripBasePath(pathname, basePath); } // Steps 6-7: RSC detection and cleanPathname. @@ -141,6 +146,7 @@ export function normalizeRscRequest( return { clientReuseManifest, + hadBasePath, url, pathname, cleanPathname, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index d128dc7991..1a474ad2ec 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1073,6 +1073,60 @@ describe("createAppRscHandler", () => { } }); + it("applies basePath false rewrites before rejecting outside-basePath requests", async () => { + // Ported from Next.js: test/e2e/app-dir/app-basepath/index.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-basepath/index.test.ts + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("outside-base-path upstream"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + + try { + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [ + { + source: "/outsideBasePath", + destination: `http://127.0.0.1:${address.port}/`, + basePath: false, + }, + ], + afterFiles: [], + fallback: [], + }, + matchRoute: () => null, + }); + + const response = await handler(new Request("https://example.test/outsideBasePath"), null); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("outside-base-path upstream"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("does not expose direct App routes outside basePath when an opt-out rule exists", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("page")); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [{ source: "/only-this-path", destination: "/about", basePath: false }], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage, + }); + + const response = await handler(new Request("https://example.test/about"), null); + + expect(response.status).toBe(404); + expect(dispatchMatchedPage).not.toHaveBeenCalled(); + }); + it("preserves Node route handler RSC URLs while hiding internal parsed params", async () => { // Ported from Next.js: // test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts diff --git a/tests/app-rsc-request-normalization.test.ts b/tests/app-rsc-request-normalization.test.ts index 50e8483392..af7ead5c0b 100644 --- a/tests/app-rsc-request-normalization.test.ts +++ b/tests/app-rsc-request-normalization.test.ts @@ -563,6 +563,14 @@ describe("normalizeRscRequest — compound scenarios", () => { expect(result.pathname).toBe("/dashboard.rsc"); expect(result.cleanPathname).toBe("/dashboard"); expect(result.isRscRequest).toBe(true); + expect(result.hadBasePath).toBe(true); + }); + + it("preserves outside-basePath pathnames when config rules opt out", () => { + const result = normalized(normalizeRscRequest(req("/outside"), "/app", true)); + expect(result.pathname).toBe("/outside"); + expect(result.cleanPathname).toBe("/outside"); + expect(result.hadBasePath).toBe(false); }); it("returns the parsed URL object so middleware can later mutate url.search", () => { From 3c428cb7e9f54cfa08e5518053edd30a45d7150c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:39:24 +0100 Subject: [PATCH 2/6] fix(app-router): preserve basePath opt-out redirects --- packages/vinext/src/server/app-rsc-handler.ts | 21 ++++++++----- tests/app-rsc-handler.test.ts | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 714afb9e1f..0f6fe575eb 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -346,12 +346,17 @@ function createMissingServerActionResponse( return createServerActionNotFoundResponse(); } -// TODO(#1333): once App Router supports `basePath: false` rules (see -// `normalizeRscRequest` — it 404s out-of-basePath requests before they -// reach this code), pass `hadBasePath` here and skip the prefix when -// false, mirroring the same guard in `prod-server.ts` and `deploy.ts`. -function redirectDestinationWithBasePath(destination: string, basePath: string): string { - if (!basePath || isExternalUrl(destination) || hasBasePath(destination, basePath)) { +function redirectDestinationWithBasePath( + destination: string, + basePath: string, + hadBasePath: boolean, +): string { + if ( + !basePath || + !hadBasePath || + isExternalUrl(destination) || + hasBasePath(destination, basePath) + ) { return destination; } return basePath + destination; @@ -534,7 +539,7 @@ async function handleAppRscRequest( const trailingSlashRedirect = normalizeTrailingSlash( pathname, - options.basePath, + hadBasePath ? options.basePath : "", options.trailingSlash, url.search, ); @@ -561,7 +566,7 @@ async function handleAppRscRequest( ); if (redirect) { const destination = sanitizeDestination( - redirectDestinationWithBasePath(redirect.destination, options.basePath), + redirectDestinationWithBasePath(redirect.destination, options.basePath, hadBasePath), ); // For RSC navigations `createRscRedirectLocation` recomputes the // cache-busting `_rsc` param onto the Location. For plain (document) diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 1a474ad2ec..6694a88834 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -712,6 +712,37 @@ describe("createAppRscHandler", () => { expect(dispatchMatchedPage).not.toHaveBeenCalled(); }); + it("does not prepend basePath to opt-out redirects outside basePath", async () => { + const handler = createHandler({ + configHeaders: [], + configRedirects: [ + { source: "/outside", destination: "/landing", permanent: false, basePath: false }, + ], + }); + + const response = await handler(new Request("https://example.test/outside"), null); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("/landing"); + }); + + it("does not prepend basePath when normalizing trailing slashes outside basePath", async () => { + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [{ source: "/outside", destination: "/about", basePath: false }], + afterFiles: [], + fallback: [], + }, + trailingSlash: true, + }); + + const response = await handler(new Request("https://example.test/outside"), null); + + expect(response.status).toBe(308); + expect(response.headers.get("location")).toBe("/outside/"); + }); + it("keeps the real status for config redirects on Pages data requests", async () => { // Ported from Next.js: test/e2e/middleware-general/test/index.test.ts // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-general/test/index.test.ts From 92aeb52902a98ed5187da60d56ad4a796daa3eea Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:43:07 +0100 Subject: [PATCH 3/6] fix(app-router): harden basePath opt-out routing --- packages/vinext/src/entries/app-rsc-entry.ts | 3 +- packages/vinext/src/server/app-middleware.ts | 14 ++--- packages/vinext/src/server/app-rsc-handler.ts | 13 +++- tests/app-rsc-handler.test.ts | 60 +++++++++++++++++++ tests/entry-templates.test.ts | 3 +- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 793354bbd7..f42cb479fa 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1162,11 +1162,12 @@ export default createAppRscHandler({ matchRoute, ${ middlewarePath - ? `runMiddleware({ cleanPathname, context, isDataRequest, request }) { + ? `runMiddleware({ cleanPathname, context, hadBasePath, isDataRequest, request }) { return __applyAppMiddleware({ basePath: __basePath, cleanPathname, context, + hadBasePath, filePath: ${JSON.stringify(middlewarePath ? normalizePathSeparators(middlewarePath) : "")}, i18nConfig: __i18nConfig, isDataRequest, diff --git a/packages/vinext/src/server/app-middleware.ts b/packages/vinext/src/server/app-middleware.ts index ae9710c30e..35d9341a57 100644 --- a/packages/vinext/src/server/app-middleware.ts +++ b/packages/vinext/src/server/app-middleware.ts @@ -19,6 +19,7 @@ export type ApplyAppMiddlewareOptions = { basePath?: string; cleanPathname: string; context: AppMiddlewareContext; + hadBasePath?: boolean; i18nConfig?: NextI18nConfig | null; /** * Whether the inbound request was recognized as a `_next/data` fetch from @@ -42,6 +43,7 @@ export type ApplyAppMiddlewareResult = | { kind: "continue"; cleanPathname: string; + rewritten: boolean; search: string | null; } | { @@ -210,6 +212,7 @@ export async function applyAppMiddleware( const forwarded = applyForwardedMiddlewareContext(options.request, options.context); const middlewareRequest = requestWithoutFlightHeaders(options.request); let cleanPathname = options.cleanPathname; + let rewritten = false; let search: string | null = null; if (forwarded.rewriteUrl) { @@ -226,6 +229,7 @@ export async function applyAppMiddleware( } const rewriteParsed = new URL(forwarded.rewriteUrl, middlewareRequest.url); cleanPathname = rewriteParsed.pathname; + rewritten = true; search = rewriteParsed.search; } catch (e) { console.error("[vinext] Failed to apply forwarded middleware rewrite:", e); @@ -236,12 +240,7 @@ export async function applyAppMiddleware( if (!forwarded.applied) { const result = await executeMiddleware({ basePath: options.basePath, - // The App Router only reaches middleware when the request was under - // basePath (already stripped by normalizeRscRequest) or basePath is - // empty — see the basePathState comment in app-rsc-handler.ts. The - // request URL here is basePath-stripped, so hadBasePath cannot be - // derived from it and must be asserted explicitly. - hadBasePath: true, + hadBasePath: options.hadBasePath ?? true, filePath: options.filePath, i18nConfig: options.i18nConfig, isDataRequest: options.isDataRequest, @@ -286,6 +285,7 @@ export async function applyAppMiddleware( } const rewriteParsed = new URL(result.rewriteUrl, middlewareRequest.url); cleanPathname = rewriteParsed.pathname; + rewritten = true; search = rewriteParsed.search; } } @@ -296,5 +296,5 @@ export async function applyAppMiddleware( processMiddlewareHeaders(options.context.headers); } - return { kind: "continue", cleanPathname, search }; + return { kind: "continue", cleanPathname, rewritten, search }; } diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 0f6fe575eb..c63edf1fd5 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -100,6 +100,7 @@ type AppRscMiddlewareContext = AppMiddlewareContext; type RunAppMiddlewareOptions = { cleanPathname: string; context: AppRscMiddlewareContext; + hadBasePath: boolean; isDataRequest: boolean; request: Request; }; @@ -604,6 +605,7 @@ async function handleAppRscRequest( const middlewareResult = await options.runMiddleware({ cleanPathname, context: middlewareContext, + hadBasePath, isDataRequest: isMiddlewareDataRequest, request: userlandRequest, }); @@ -617,7 +619,7 @@ async function handleAppRscRequest( } cleanPathname = middlewareResult.cleanPathname; - didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname; + didMiddlewareRewrite = middlewareResult.rewritten; if (middlewareResult.search !== null) { url.search = middlewareResult.search; } @@ -737,7 +739,12 @@ async function handleAppRscRequest( const isPostRequest = request.method.toUpperCase() === "POST"; let progressiveActionResult: Response | ProgressiveActionFormStateResult | null = null; - if (isPostRequest && contentType.startsWith("multipart/form-data") && !actionId) { + if ( + filesystemRouteEligible && + isPostRequest && + contentType.startsWith("multipart/form-data") && + !actionId + ) { if (options.handleProgressiveActionRequest) { progressiveActionResult = await options.handleProgressiveActionRequest({ actionId, @@ -777,7 +784,7 @@ async function handleAppRscRequest( } const serverActionResponse = - isPostRequest && actionId && options.handleServerActionRequest + filesystemRouteEligible && isPostRequest && actionId && options.handleServerActionRequest ? await options.handleServerActionRequest({ actionId, cleanPathname, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 6694a88834..6c5ef8268f 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1158,6 +1158,66 @@ describe("createAppRscHandler", () => { expect(dispatchMatchedPage).not.toHaveBeenCalled(); }); + it("does not dispatch server actions outside basePath when an opt-out rule exists", async () => { + const handleServerActionRequest = vi.fn(async () => new Response("action")); + const handler = createHandler({ + configHeaders: [{ source: "/outside", headers: [], basePath: false }], + handleServerActionRequest, + }); + + const response = await handler( + new Request("https://example.test/about", { + method: "POST", + headers: { "next-action": "abc123" }, + }), + null, + ); + + expect(response.status).toBe(404); + expect(handleServerActionRequest).not.toHaveBeenCalled(); + }); + + it("passes outside-basePath state to middleware", async () => { + let pathname: string | undefined; + let basePath: string | undefined; + const handler = createHandler({ + configHeaders: [{ source: "/outside", headers: [], basePath: false }], + middlewareModule: { + default: (request: Request & { nextUrl: URL & { basePath: string } }) => { + pathname = request.nextUrl.pathname; + basePath = request.nextUrl.basePath; + return new Response(null, { headers: { "x-middleware-next": "1" } }); + }, + }, + }); + + await handler(new Request("https://example.test/outside"), null); + + expect(pathname).toBe("/outside"); + expect(basePath).toBe(""); + }); + + it("allows query-only middleware rewrites to make outside-basePath routes eligible", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("page")); + const handler = createHandler({ + configHeaders: [{ source: "/about", headers: [], basePath: false }], + dispatchMatchedPage, + middlewareModule: { + default: (request: Request) => + new Response(null, { + headers: { + "x-middleware-rewrite": new URL("/about?from=middleware", request.url).toString(), + }, + }), + }, + }); + + const response = await handler(new Request("https://example.test/about"), null); + + expect(response.status).toBe(200); + expect(dispatchMatchedPage).toHaveBeenCalled(); + }); + it("preserves Node route handler RSC URLs while hiding internal parsed params", async () => { // Ported from Next.js: // test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 8ccae8ad1f..43e939db29 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -834,8 +834,9 @@ describe("App Router entry templates", () => { expect(withoutMiddleware).not.toContain("app-middleware.js"); expect(withoutMiddleware).not.toContain("runMiddleware("); expect(withMiddleware).toContain("app-middleware.js"); - expect(withMiddleware).toContain("runMiddleware({ cleanPathname"); + expect(withMiddleware).toContain("runMiddleware({ cleanPathname, context, hadBasePath"); expect(withMiddleware).toContain("return __applyAppMiddleware({"); + expect(withMiddleware).toContain("hadBasePath,"); }); it("generateRscEntry only includes the PPR runtime when Cache Components is enabled", () => { From edaeaf89d55d59805ad2ce709591e1b2c8dd226e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:46:06 +0100 Subject: [PATCH 4/6] fix(app-router): admit basePath middleware requests --- packages/vinext/src/server/app-rsc-handler.ts | 20 ++++++++++--------- tests/app-rsc-handler.test.ts | 15 ++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c63edf1fd5..29b506c62d 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -488,14 +488,16 @@ async function handleAppRscRequest( if (originBlock) return originBlock; } - const hasBasePathOptOutRule = [ - ...options.configRedirects, - ...options.configRewrites.beforeFiles, - ...options.configRewrites.afterFiles, - ...options.configRewrites.fallback, - ...options.configHeaders, - ].some((rule) => rule.basePath === false); - const normalized = normalizeRscRequest(request, options.basePath, hasBasePathOptOutRule); + const canHandleOutsideBasePath = + Boolean(options.runMiddleware) || + [ + ...options.configRedirects, + ...options.configRewrites.beforeFiles, + ...options.configRewrites.afterFiles, + ...options.configRewrites.fallback, + ...options.configHeaders, + ].some((rule) => rule.basePath === false); + const normalized = normalizeRscRequest(request, options.basePath, canHandleOutsideBasePath); if (normalized instanceof Response) return normalized; const { @@ -798,7 +800,7 @@ async function handleAppRscRequest( }) : null; if (serverActionResponse) return serverActionResponse; - if (isPostRequest && actionId && !options.handleServerActionRequest) { + if (filesystemRouteEligible && isPostRequest && actionId && !options.handleServerActionRequest) { return createMissingServerActionResponse(options, actionId); } diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 6c5ef8268f..73426cd7aa 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1197,6 +1197,21 @@ describe("createAppRscHandler", () => { expect(basePath).toBe(""); }); + it("allows middleware-only apps to handle requests outside basePath", async () => { + const handler = createHandler({ + configHeaders: [], + middlewareModule: { + default: (request: Request) => + Response.redirect(new URL("/docs/about", request.url).toString(), 307), + }, + }); + + const response = await handler(new Request("https://example.test/outside"), null); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("/docs/about"); + }); + it("allows query-only middleware rewrites to make outside-basePath routes eligible", async () => { const dispatchMatchedPage = vi.fn(async () => new Response("page")); const handler = createHandler({ From 52fd5f3d17d0583a86f5cc5e1224d40118082f36 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:56:36 +0100 Subject: [PATCH 5/6] fix(app-router): reject untouched basePath misses --- packages/vinext/src/server/app-rsc-handler.ts | 7 +++++++ tests/app-rsc-handler.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 29b506c62d..0ef24acae7 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -919,6 +919,13 @@ async function handleAppRscRequest( } } + if (!filesystemRouteEligible) { + options.clearRequestContext(); + const headers = new Headers(); + mergeMiddlewareResponseHeaders(headers, middlewareContext.headers); + return notFoundResponse({ headers }); + } + if (pagesDataRequest) { options.clearRequestContext(); if ( diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 73426cd7aa..647692f7cf 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1212,6 +1212,23 @@ describe("createAppRscHandler", () => { expect(response.headers.get("location")).toBe("/docs/about"); }); + it("returns a plain 404 when middleware leaves an outside-basePath request unchanged", async () => { + const renderNotFound = vi.fn(async () => new Response("rendered not found", { status: 404 })); + const handler = createHandler({ + configHeaders: [], + middlewareModule: { + default: () => new Response(null, { headers: { "x-middleware-next": "1" } }), + }, + renderNotFound, + }); + + const response = await handler(new Request("https://example.test/outside"), null); + + expect(response.status).toBe(404); + expect(await response.text()).not.toBe("rendered not found"); + expect(renderNotFound).not.toHaveBeenCalled(); + }); + it("allows query-only middleware rewrites to make outside-basePath routes eligible", async () => { const dispatchMatchedPage = vi.fn(async () => new Response("page")); const handler = createHandler({ From e7da1201725c64821550f85549d541dba717c439 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 25 Jun 2026 19:57:18 +0100 Subject: [PATCH 6/6] fix(app-router): scope pages fallback rewrites --- packages/vinext/src/server/app-rsc-handler.ts | 4 ++- tests/app-rsc-handler.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 0ef24acae7..c6567f4603 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -602,6 +602,7 @@ async function handleAppRscRequest( status: null, }; let didMiddlewareRewrite = false; + let didMiddlewareRewritePathname = false; if (options.runMiddleware) { const middlewareResult = await options.runMiddleware({ @@ -622,6 +623,7 @@ async function handleAppRscRequest( cleanPathname = middlewareResult.cleanPathname; didMiddlewareRewrite = middlewareResult.rewritten; + didMiddlewareRewritePathname = cleanPathname !== normalized.cleanPathname; if (middlewareResult.search !== null) { url.search = middlewareResult.search; } @@ -813,7 +815,7 @@ async function handleAppRscRequest( match === null || match.route.isDynamic ? ((await options.renderPagesFallback?.({ appRouteMatch: match ?? null, - allowRscDocumentFallback: didMiddlewareRewrite, + allowRscDocumentFallback: didMiddlewareRewritePathname, isDataRequest, isRscRequest, matchKind, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 647692f7cf..a9eaa95843 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -917,6 +917,35 @@ describe("createAppRscHandler", () => { expect(await response.text()).toBe("pages"); }); + it("does not hand query-only middleware-rewritten RSC requests to Pages HTML", async () => { + const headers = createRscRequestHeaders(); + const rscUrl = await createRscRequestUrl("/docs/source", headers); + const renderPagesFallback = vi.fn(async ({ allowRscDocumentFallback }) => + allowRscDocumentFallback + ? 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/source?query=updated" }, + }), + }, + renderPagesFallback, + }); + + const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null); + + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).not.toBe("text/html"); + expect(renderPagesFallback).toHaveBeenCalledWith( + expect.objectContaining({ allowRscDocumentFallback: false }), + ); + }); + it("does not duplicate additive config headers on non-redirect middleware responses", async () => { const handler = createHandler({ configHeaders: [