Skip to content

fix(app-router): honor basePath false rewrites outside basePath#2117

Open
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-basepath-parity
Open

fix(app-router): honor basePath false rewrites outside basePath#2117
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-basepath-parity

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Item Detail
Goal Match Next.js App Router behaviour for same-origin absolute redirects that land outside basePath when a basePath: false rewrite owns that path.
Core change Delay App Router out-of-basePath rejection long enough for config and middleware rewrites to match, while keeping filesystem routes gated until a rewrite makes them reachable.
Primary files packages/vinext/src/server/app-rsc-request-normalization.ts, packages/vinext/src/server/app-rsc-handler.ts, tests/e2e/app-basepath/app-basepath.spec.ts
Expected impact The upstream test/e2e/app-dir/app-basepath/index.test.ts now passes, including the previously failing external redirect case.

Why

App Router basePath handling has two separate responsibilities: normalization must know whether the incoming URL carried the configured basePath, and rewrite matching must still be able to evaluate explicit basePath: false routes. Collapsing out-of-basePath requests into an early 404 loses the information needed to match those explicit routes.

Area Principle / invariant What this PR changes
Request normalization Preserve validated request shape without prematurely deciding rewrite eligibility. normalizeRscRequest() now records hadBasePath and can preserve out-of-basePath paths for callers that need delayed rejection.
App Router routing Filesystem routes should remain scoped to basePath unless middleware or config rewrites intentionally route elsewhere. App RSC handling gates image, metadata, public files, route matching, and pages fallback behind a shared filesystem eligibility check.
Next.js parity basePath: false custom rewrites can match paths outside the configured basePath. The App Router now lets those rewrite rules proxy external destinations before returning 404.

What changed

Scenario Before After
POST /base/client server action redirects to origin + "/outsideBasePath" Browser followed the redirect, but GET /outsideBasePath returned 404 before the external rewrite could match. GET /outsideBasePath matches the basePath: false external rewrite and returns 200.
Out-of-basePath request with no matching rewrite Could be delayed by the new normalization path. Still cannot reach image optimization, metadata routes, public files, app routes, or pages fallback.
Internal redirects from under basePath Existing basePath prefixing remains intact. Relative and same-origin absolute internal action redirects still stream as a single 303 response.
Maintainer review path
  1. packages/vinext/src/server/app-rsc-request-normalization.ts
    • Review the new hadBasePath result and allowOutOfBasePath option.
  2. packages/vinext/src/server/app-rsc-handler.ts
    • Review how hadBasePath, config rewrites, and middleware rewrites feed the shared filesystem route gate.
  3. tests/app-rsc-handler.test.ts
    • Review the focused handler coverage for external basePath: false rewrites and the negative filesystem exposure guard.
  4. tests/e2e/app-basepath/app-basepath.spec.ts
    • Review the upstream app-basepath server action redirect port.
  5. playwright.config.ts, .github/workflows/ci.yml, knip.ts
    • Review the dedicated E2E project and CI registration.
Validation
  • vp test run tests/app-rsc-handler.test.ts tests/app-rsc-request-normalization.test.ts
  • PLAYWRIGHT_PROJECT=app-basepath vp run test:e2e
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/app-basepath/index.test.ts
  • vp check
  • vp run knip
Risk / compatibility
  • Public API: none.
  • Config compatibility: improves parity for basePath: false App Router rewrites.
  • Runtime: out-of-basePath requests are preserved only inside App Router handling so explicit rewrites can run. Filesystem-like handlers remain unavailable until a config or middleware rewrite opts the request back into route matching.
  • Existing apps: apps without basePath or without basePath: false rewrites should keep existing behaviour.
Non-goals
  • This does not attempt to close the known config headers timing parity gap documented in AGENTS.md.
  • This does not broaden App Router basePath behaviour beyond explicit config or middleware rewrites.

References

Reference Why it matters
Next.js app-basepath test Source of the upstream behaviour port, including the previously failing external redirect case.
Next.js action handler Shows same-origin absolute redirects under basePath are app-relative, while same-origin absolute redirects outside basePath fall back to browser navigation.
Next.js route resolution Shows custom route matching runs with basePath state instead of treating every out-of-basePath path as an unconditional early 404.
Next.js custom route loading Documents validation around custom route basePath: false handling.
Next.js basePath docs Public contract for basePath configuration.
Next.js rewrites docs Public contract for rewrites, including custom routing behaviour.

@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2117
npm i https://pkg.pr.new/vinext@2117

commit: 48b8fe4

App Router requests outside a configured basePath were rejected during request normalization before next.config rewrites could match. This made same-origin server-action redirects to an absolute URL outside basePath fall through to 404 even when a basePath:false external rewrite should proxy it.

The broken invariant was that basePath membership has to remain visible through rewrite matching, not be collapsed into an early 404. Normalization now records whether the original request had the basePath and lets the App Router delay rejection; filesystem routes stay gated until a config or middleware rewrite makes the request routable.

Adds focused handler and normalization coverage plus an E2E port of the upstream app-basepath server action redirect cases.
Out-of-basePath App Router requests with no matching rewrite fell through
to renderNotFound(), rendering the app's not-found page. That page is a
filesystem route, so the render loaded the app's client references. Under
startProdServer's process-wide RSC globals, a second build (the basePath
prod-server test) thereby corrupted the active client-reference manifest,
breaking later requests on the shared server with "client reference not
found" — 13 failures in tests/app-router-production-server.test.ts.

normalizeRscRequest previously 404'd these requests before they reached
the handler, so the not-found render was never exercised. The PR's
allowOutOfBasePath path removed that early exit but left renderNotFound
ungated, unlike the metadata, public-file, and page handlers.

Gate renderNotFound behind canUseFilesystemRoutes() and fall through to a
plain 404, matching the other filesystem handlers and Next.js behavior
for paths outside basePath with no basePath:false rewrite.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/app-basepath-parity branch from b37a735 to 4ae6f90 Compare June 17, 2026 16:37
NathanDrake2406 and others added 4 commits June 18, 2026 16:00
…em eligibility

Out-of-basePath POST requests with an action id could still execute server
actions and progressive form actions because handleServerActionRequest and
handleProgressiveActionRequest were dispatched based only on method and
content-type checks, without consulting the canUseFilesystemRoutes() gate
that protects every other filesystem entrypoint. Both action handlers call
matchRoute() internally, so they could load and execute app route code for
paths that image optimisation, metadata routes, and public files are
correctly blocked from reaching.

Gate both action dispatch paths behind canUseFilesystemRoutes() so an
out-of-basePath request that no middleware or config rewrite opted back in
gets 404 before any action code runs. A middleware rewrite that changes the
pathname still opens the gate, preserving the basePath:false rewrite flow.

Also make applyAppMiddleware return an explicit didRewrite boolean instead
of having the caller infer it by comparing cleanPathname before and after.
The forensic comparison was serviceable but brittle: it proved the pathname
changed, not that middleware explicitly rewrote. The explicit signal makes
the boundary mechanical and robust against future code that might modify
cleanPathname for non-rewrite reasons.

Tests: out-of-basePath server action POST, progressive form POST, and
middleware-rewrite-opts-back-in action dispatch.
Resolve conflict in app-rsc-handler.ts: merge the canUseFilesystemRoutes()
gate for metadata routes with main's performance optimization (early-exit
when no metadata routes exist, defer handleMetadataRouteRequest import).
Resolve conflicts in app-middleware.ts and app-rsc-handler.ts: keep
hadBasePath pass-through (needed for out-of-basePath middleware evaluation)
alongside main's new filePath option for middleware module resolution.
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 48b8fe4 against base 42f8700.

0 improved · 4 regressed · 2 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) Next.js 185.6 KB 185.6 KB ⚫ 0.0%
Client bundle size (gzip) vinext 129.9 KB 129.9 KB ⚫ -0.0%
Dev server cold start Next.js 2.26 s 2.52 s 🔴 +11.5%
Dev server cold start vinext 2.44 s 2.58 s 🔴 +5.5%
Production build time Next.js 3.98 s 4.17 s 🔴 +4.7%
Production build time vinext 2.66 s 2.77 s 🔴 +4.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5%

The external redirect test counted all non-_next requests and expected
exactly two. A stray /favicon.ico fetch from Chromium after document
navigation would break the toHaveLength(2) assertion even though the
behaviour under test was correct. Filter by the two specific paths under
test (/base/client and /outsideBasePath), matching the pattern the first
test already uses.
Out-of-basePath App Router requests are preserved (not 404'd) so config
`basePath: false` rewrites can still match them. But the handler ran
`normalizeTrailingSlash` unconditionally, and that helper always rebuilds the
Location as `basePath + pathname`. For a delayed-rejection out-of-basePath
request `hadBasePath` is false and `pathname` is still un-stripped, so e.g.
`GET /outsideBasePath/` (basePath `/base`, trailingSlash false) returned
`308 Location: /base/outsideBasePath`, pushing the request back under basePath
before its `basePath: false` rewrite could fire. The mirror case happens with
`trailingSlash: true` and a request missing the slash.

Next.js models trailing-slash normalisation as a basePath-scoped internal
redirect: the auto-generated `/:path+/` rule carries the basePath (no
`basePath: false`), so it compiles to `/base/:path+/` and structurally never
matches out-of-basePath paths. Gate the redirect on `hadBasePath` to match,
preserving out-of-basePath requests for their explicit rewrites.

Adds handler tests covering both trailingSlash settings, each triggered by the
request shape that exercises its redirect branch.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 19, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant