Hybrid App Router plus Pages Router production builds entered through the App Router server and only delegated route misses to renderPage. That meant pages/api routes were outside the fallback path and returned the App Router 404 in mixed apps.
The generated RSC entry now dispatches /api misses to the Pages server entry handleApiRoute, applies middleware response effects to that API response, and the hybrid SSR bridge re-exports the API dispatcher. Regression coverage ports the Next.js og-api Pages API ImageResponse case at the production boundary.
App Router RSC deployment mismatches and related fallback paths could still write directly to window.location outside the navigation controller. That bypassed the persisted same-target hard-navigation guard, so a canary or rollback mismatch could repeatedly reload the current document instead of stopping after the first hard navigation.
Expose the controller's guarded hard-navigation executor and route RSC compatibility fallbacks, server-action deployment fallbacks, and RSC navigation error fallbacks through it. Add skip/cache canary rollback compatibility coverage plus a direct fallback guard regression test.
* fix(router): preserve default locale for unprefixed root links
Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale.
Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries.
* refactor(router): share client locale inference
The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other.
Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href.
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.
Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.
Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.
The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:
- `prod-server.ts` (Pages Router prod)
- `app-rsc-handler.ts` (App Router dev + prod)
- `index.ts` (Pages Router dev plugin)
- `deploy.ts` (Cloudflare Workers entry template)
The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.
The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.
Refs #1336 (item 4)
* feat(cache): add skip cache proof cross-checks
Skip manifest entries remain untrusted hints, but future skip transport needs a cheap server-side proof boundary before it can omit any payload. Cache presence, client-declared artifact metadata, and stored artifact metadata are not individually enough to prove correctness.
Add a pure skip/cache cross-check helper that requires an accepted static layout cache proof, compatible artifact metadata, matching cache variant, matching payload hash, and valid invalidation proof. The helper always returns the disabled render-and-send skip disposition in this slice.
Cover graph, deployment, render epoch, variant, payload hash, invalidation, missing proof, and rejected proof cases at the helper boundary.
* fix(cache): bind skip cache artifact proof metadata
Accepted static layout artifact reuse decisions proved candidate artifact compatibility but did not carry that compatibility envelope forward. That let skip/cache cross-checks accept independently supplied artifact metadata as long as the client entry matched it, even when the cache proof had authorized a different artifact.
Store a snapshot of the candidate artifact compatibility on accepted artifact reuse proofs and require skip/cache cross-checks to match it exactly before checking client compatibility, variants, payload hashes, or invalidation state.
The skip/cache test matrix now covers the rejected proof-binding case, root-boundary compatibility mismatches, and layout entry id mismatches.
* feat(config): add experimental.appShells plumbing
Accepts experimental.appShells in next.config and defines
process.env.__NEXT_APP_SHELLS=false at build time, matching
Next.js PR #93997 plumbing scope. Behavorial implementation
is gated on upstream co-flags that vinext does not yet support.
- Adds experimental.appShells to CONFIG_SUPPORT as unsupported
- Sets __NEXT_APP_SHELLS define to false regardless of config value
- Tests for vinext check detection and Vite define injection
Fixes#1405
* review: address PR nits
- Expand experimental.appShells detail to explain why it's unsupported
- Move build-time define tests into their own describe block, so the
__NEXT_APP_SHELLS define test isn't nested under basePath
* review: drop orphaned server lifecycle from basePath describe
The two remaining resolveNextConfig tests import from source directly
and don't use the dev server.
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): render optimistic loading shells for dynamic navigation
Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.
The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.
Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.
* chore(app-router): address optimistic routing review
has been stable since Next.js stabilized the feature upstream.
- Emit a deprecation warning when users still set
- Matches Next.js upstream wording for parity
Closes#1325
* fix(pages-router): render loading shell for unlisted `fallback: true` paths
PR #1394 fixed the post-rewrite static-vs-dynamic priority sub-problem
from #1331 and explicitly tracked `fallback: true` rewrite behaviour
separately. When `getStaticPaths` returns `fallback: true` and a request
hits a path not in the pre-rendered list, vinext was SSRing the page
fully — running `getStaticProps` and emitting `isFallback: false` in
`__NEXT_DATA__`. Next.js instead ships a loading shell with empty props
and `useRouter().isFallback === true`, then the client hydrates by
fetching the data URL. The upstream regression test
`should rewrite to fallback: true page successfully` in
`test/e2e/middleware-rewrites/test/index.test.ts` asserts "Loading..."
in the SSR HTML, which vinext could not produce.
Thread an `isFallback` signal through `resolvePagesPageData`,
`buildPagesNextDataScript`, the Pages Router SSR context, and both the
prod (`pages-server-entry.ts`) and dev (`dev-server.ts`) renderers:
- `getStaticProps` and `getServerSideProps` are skipped on the fallback
render (matches Next.js `render.tsx`'s `if (isSSG && !isFallback)`
gate around `getStaticProps`).
- Data requests (`/_next/data/<buildId>/<page>.json`) still resolve real
props so the client can swap in after the shell ships.
- `useRouter().isFallback` reads from the SSR context server-side and
from `__NEXT_DATA__.isFallback` client-side — both flip to true on
the shell render.
The dev-server comment that claimed Next.js dev "also renders fully"
was incorrect; Next.js's `pages-handler.ts` invokes `doRender` with
`isFallback: true` for dev fallback responses, so dev now serves the
shell too.
Tests:
- Updated `tests/pages-router.test.ts` "SSR renders unlisted path with
getStaticPaths fallback: true" — previously documented the deviation;
now asserts the loading shell, empty props, and `isFallback: true`.
- Added a counterpart test that the data URL for the same unlisted
path still returns real props from `getStaticProps`.
- Added a production-side regression test mirroring the upstream
"should rewrite to fallback: true page successfully" expectation.
Refs #1331
* test(pages-page-data): include isFallback: false in normalized render assertion
The render branch of `resolvePagesPageData` now returns `isFallback`
alongside the other fields (added in the parent commit for the
`fallback: true` loading shell). Update the strict-shape `toEqual` in
the cache-miss unit test so it matches the new return shape.
* fix(middleware): preserve route priority on rewrite target resolution
When `trailingSlash: true` is set in next.config.js, a request like
`/rewrite-1/` was matched against the next.config.js rewrite source
`/rewrite-1` literally, including the trailing slash. The match failed,
so the request fell through to the dynamic `[id]` route and rendered
the dynamic page with `id="rewrite-1"` instead of being rewritten to
the static target.
Strip a single trailing slash from the incoming pathname before
matching against config rewrite/redirect/header sources, matching
Next.js's `source + '(/)?'` behavior under `trailingSlash: true`. The
source pattern itself is preserved so catch-all (`:param*`, `:param+`)
and root (`/`) semantics are unaffected.
Refs #1331
* address bonk feedback: locale-static path, comment wording, tighter tests
- Extract `stripTrailingSlashForConfigMatch` and call it from
`matchRedirect` so the locale-static fast path (Map.get on the
pathname) also honors trailing slashes — previously a pure
locale-static redirect rule could miss when `trailingSlash: true`.
- Clarify the comment to reflect that the strip is unconditional
defense-in-depth rather than a faithful port of Next.js's
conditional `(/)?` suffix.
- Disambiguate the pages-router integration tests by asserting on
`id="home"` / `id="ssr"` so the assertion can't pass on the wrong
page (both pages render "Hello World").
- Add direct unit tests for `matchConfigPattern` trailing-slash
behavior covering exact, :param, regex-group, root, and catch-all
cases.
* extend trailing-slash normalization to matchHeaders
Bonk flagged that matchHeaders runs its own compiled regex path that
doesn't go through matchConfigPattern, so a header source like
`/api/:path*` would miss `/api/foo/` under `trailingSlash: true`.
Normalize once at the top of matchHeaders the same way matchRedirect
already does, and add a regression test.
Closes the last sub-problem of #1332.
The Pages Router `Router.push` / `Router.replace` already short-circuit the
`_next/data` fetch when `options.shallow === true`, but `<Link shallow>` never
made it to the router because `shallow` was missing from `LinkProps`. The prop
silently fell into `...rest`, was spread onto the underlying `<a>`, and the
click handler called `Router.push(href, undefined, { scroll, locale })` with
no shallow flag — so every `<Link shallow>` did a full server roundtrip and
GSSP re-ran.
This matches Next.js's deploy-suite fixture
`test/e2e/middleware-trailing-slash/app/pages/shallow.js`, which clicks a
`<Link href="/sha?hello=world" shallow>` and asserts the GSSP-generated
random message stays unchanged afterwards.
The fix is mechanical: add `shallow?: boolean` to `LinkProps`, destructure
it in the Link component, forward it through `navigatePagesRouterLink` into
the router transition options. `shallow` is only included in the router
options when explicitly set so existing callers that omit it keep the same
options shape.
Plumb `trailingSlash` from the resolved next.config into NextURL via
NextRequest, so `NextResponse.redirect(request.nextUrl)` (and any other
stringification of a NextURL) emits a Location header that matches the
user's slash policy. Mirrors Next.js's `formatNextPathnameInfo`.
Pre-route trailing-slash normalization (e.g. `/foo` -> `/foo/` when
trailingSlash:true) already runs at every entry point via
`normalizeTrailingSlash` in request-pipeline. This commit closes the
remaining gap by making redirect Locations produced from inside
middleware (the common one-hop `request.nextUrl` redirect pattern in
the Next.js middleware-trailing-slash fixture) carry the slash too.
Refs #1332. Sub-problem 3 (shallow navigation triggering a server
roundtrip) is a separate Pages Router client-side concern and deferred
as follow-up.
* fix(assets): default assetsDir to _next/static (Next.js parity)
Closes#1337. Supersedes #1383.
vinext's default `assetsDir` was Vite's historical `assets/`, so URL emission
(`/_next/static/...` via `resolveAssetUrlPrefix("")`) and on-disk layout
(`dist/client/assets/...`) disagreed in the empty-`assetPrefix` case. The
`build.assetsDir` and `experimental.renderBuiltUrl` Vite overrides only
applied when `assetPrefix` was configured, leaving the no-prefix branch
serving from a different path than the URL contract Next.js's client
runtime and test harness assert against.
This commit flips the default to Next.js's canonical layout:
- `resolveAssetsDir("")` returns `_next/static` (was `assets`)
- `build.assetsDir` is now set unconditionally from `resolveAssetsDir`
- Vite's default `base + assetsDir` composition produces correct URLs
in the no-prefix case; `renderBuiltUrl` stays gated on `assetPrefix`
because it's only needed for the configured cases
- Drops the legacy `/assets/` branches in prod-server (3 sites) and
static-file-cache (hard cutover — see PR description for rationale)
- Updates `_headers` generation, precompress default, fonts plugin
`DEFAULT_ASSETS_DIR` to match the new default
With the layout aligned, invalid `_next/static/*` requests naturally
return plain-text `404 + "Not Found"` from the static-file layer instead
of falling through to the page renderer (which would produce an HTML
404 with bootstrap scripts and CSS). This replaces #1383's parity
short-circuit with the natural code path:
- Node prod-server (App + Pages branches): missing asset under
`resolveAppRouterAssetPath` returns `text/plain; charset=utf-8` 404
- Cloudflare worker entry: `isNextStaticPath` recognises asset-shape
after ASSETS-binding misses; returns `notFoundStaticAssetResponse`
Mirrors Next.js: packages/next/src/server/lib/router-server.ts.
Adds `tests/invalid-static-asset-404.test.ts` (3 App Router + 3 Pages
Router cases — no prefix / basePath / assetPrefix) ported from Next.js
e2e suites. Updates 11 source files + 12 test files; 914+ tests pass
in the touched suites (asset-prefix, app-router, pages-router, deploy,
font-google, static-file-cache, serve-static, precompress, features,
routing, isr-cache, build-optimization, app-rsc/ssr, standalone,
middleware, shims).
Ported from Next.js:
- test/e2e/invalid-static-asset-404-app/*.test.ts
- test/e2e/invalid-static-asset-404-pages/*.test.ts
* fix(e2e): update cloudflare-pages-router hydration spec for _next/static
* PR #1411 approved. Clean fix, 2 nits.
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
* refactor(assets): derive isHashed checks from ASSET_PREFIX_URL_DIR
Replace hardcoded '_next/static' string literals in runtime code paths
with the ASSET_PREFIX_URL_DIR constant so the asset URL convention has
a single source of truth.
- server/prod-server.ts isHashed: pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`)
- server/prod-server.ts public-dir guard: startsWith(`/${ASSET_PREFIX_URL_DIR}/`)
- server/static-file-cache.ts isHashed: both startsWith + includes derived
- plugins/fonts.ts DEFAULT_ASSETS_DIR = ASSET_PREFIX_URL_DIR
Behavioural no-op — the constant is '_next/static' so the resolved
strings are identical. Centralises the URL contract so future changes
to ASSET_PREFIX_URL_DIR (or a refactor that derives it from build
config) flow through to every consumer.
Doc-comment references to '_next/static' left in place — they
describe the canonical value for human readers and are not code paths.
---------
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
Match Next.js's observable behaviour when `<Link>` clicks, `router.push`,
`router.replace`, and `router.prefetch` block a `javascript:` (or `data:`/
`vbscript:`) URL. Vinext already prevented navigation, but the
`console.error` that Next.js's E2E suite asserts on
(`test/e2e/app-dir/javascript-urls`) was missing because:
- App Router `router.push`/`replace`/`prefetch` threw synchronously
inside `React.startTransition`, so the throw never reached React's
event-handler error reporter.
- `<Link>` rendered an inert anchor with no `href`, so clicking did
nothing — no throw, no log.
- Pages Router `next/router` had no `javascript:` guard at all and
would fall through to `window.location.assign(...)`.
Centralise the matching message in `url-safety.ts` via a new
`reportBlockedDangerousNavigation()` helper. `assertSafeNavigationUrl`
now logs before throwing, and Link's dangerous-click branch and the
Pages Router `performNavigation` both invoke the guard so the throw +
console.error pair mirrors Next.js exactly.
Closes#1349
References:
- packages/next/src/client/components/app-router-instance.ts:345,402,442,460
- packages/next/src/client/components/segment-cache/navigation.ts:537
- packages/next/src/shared/lib/router/router.ts:1025,1057
* feat(pages-router): implement _next/data JSON endpoint
Recognize `/_next/data/<buildId>/<page>.json` in dev and prod request
pipelines. The URL is normalized to the page path BEFORE middleware
runs (matching Next.js' `handleNextDataRequest`), and the server emits
`{ pageProps }` as JSON with `Content-Type: application/json` instead
of the rendered HTML.
Closes#1330
* fix(pages-router): address bigbonk findings on _next/data endpoint
- Delegate JSON envelope construction from the generated entry to the
typed `buildNextDataJsonResponse` helper so the envelope shape lives
in one place (entries stay thin per AGENTS.md).
- Return a JSON 404 from `resolvePagesPageData` when `getStaticPaths`
with `fallback: false` rejects the path for a data request. Without
this, client-side navigations to unlisted slugs got an HTML 404 body
which fails to parse as JSON in the Next.js client router.
- Apply the same JSON-404 gating in the dev server for
`getStaticPaths fallback:false`, `getServerSideProps.notFound`, and
`getStaticProps.notFound` paths.
- Read the dev buildId from the plugin's resolved `nextConfig.buildId`
first so a user-supplied `generateBuildId` is honored, matching the
value embedded into the prod server entry.
- Add coverage for `getStaticProps` data requests and the
`fallback:false` JSON-404 path.
* chore: drop unused NextDataMatch export (knip)
* fix(pages-router): JSON 404 for dev stale buildId; HTML 404 for gSSP/gSP notFound on HTML nav
Follow-ups from bigbonk round 2:
- Dev request pipeline now returns a JSON 404 when `_next/data/<wrong>` is
hit, mirroring the prod-server path. Previously the dev fall-through
served Vite's HTML 404, breaking client routers that try to parse the
body as JSON.
- `resolvePagesPageData` now gates the gSSP/gSP `notFound: true` 404 body
on `isDataReq` so HTML navigations get the HTML 404 page and data
requests get the JSON envelope. Previously both shapes always returned
the JSON body, which surfaced text-content-type mismatches on HTML
navigations through gSSP/gSP `notFound`.
* fix(rsc): redirect returns flight payload (200) for RSC navigations
When a server component calls `redirect()` and the client makes an RSC
navigation request (`.rsc` URL), encode the redirect as a React error
chunk inside the flight stream and return HTTP 200 instead of a raw 307.
The client's `RedirectErrorBoundary` decodes the `NEXT_REDIRECT;...`
digest and performs the navigation — matching Next.js's behavior in
`app-render.tsx generateDynamicFlightRenderResult`.
`generateMetadata()` redirects now always return 200 with a flight
payload (both RSC and SSR document requests), because metadata is
suspended/streamed in Next.js — its redirects ride inside the flight
body, never the HTTP status line. Page-component redirects on SSR
document requests still emit a 307, unchanged.
Closes#1347
* fix(rsc): preserve soft-nav and useTransition pending across flight redirect
The previous commit returned a 200 + flight payload for RSC `redirect()`
responses but left vinext's client-side navigation loop without a way to
detect the redirect — the 200 response fell through to React's render,
threw at decode time, and hit the catch path's `window.location.href` hard
navigation. That tore down the page mid-transition and clobbered
`useTransition`'s isPending state.
Add an out-of-band header (`X-Vinext-Rsc-Redirect`) that carries the
redirect target alongside the flight body. The browser-entry loop now
detects the header right after the existing `resolveRscRedirectLifecycleHop`
(307-following) check, drains the body, and `continue`s the loop with the
new href. The same `pendingRouterState` spans both hops, so the outer
transition stays pending continuously — restoring pre-1347 behavior.
Also stamp the build-time `X-Vinext-Rsc-Compatibility-Id` header on the
flight-redirect response. Without it, `resolveRscCompatibilityNavigationDecision`
treats the response as cross-build and hard-navigates before the redirect
header is read.
Export `MAX_RSC_REDIRECT_DEPTH` from `app-browser-rsc-redirect` so the new
header path can reuse the same hop-limit ceiling.
* fix(i18n): strip locale prefix for API routes
Mirror Next.js's behaviour where a locale-prefixed request path like
`/fr/api/ok` is matched against `pages/api/ok` after stripping the
locale segment. Previously the locale prefix was kept through the
`/api/` startsWith check, so any locale-prefixed API URL 404'd.
Add a small helper `stripI18nLocaleForApiRoute(url, i18nConfig)` in
`server/pages-i18n.ts` that mirrors Next.js's
`normalizeLocalePath().pathname` (see
packages/next/src/shared/lib/i18n/normalize-locale-path.ts) and wire it
into all three Pages Router request pipelines so dev/prod parity holds:
- dev plugin: `packages/vinext/src/index.ts`
- prod (Node): `packages/vinext/src/server/prod-server.ts`
- prod (Cloudflare worker entry): `packages/vinext/src/deploy.ts`
App Router is intentionally unchanged: Next.js does not support the
`i18n` config field with App Router, so App Router route handlers
under `app/*/route.ts` never see a locale prefix to strip.
Refs #1336 (item 3). Items 2 (sticky locale on client navigations) and
4 (default-locale path normalisation) are being handled in parallel
PRs.
Tests:
- Unit tests for `stripI18nLocaleForApiRoute` covering basic, regional
(`nl-NL`), query-preserving, unconfigured-prefix, and null-config
cases (`tests/pages-i18n.test.ts`).
- Dev integration tests (`tests/features.test.ts`) and prod integration
tests (`tests/pages-i18n-prod.test.ts`) that GET `/fr/api/ok` and
assert a 200 "ok" response against the existing
`pages-i18n-domains` fixture, ported from Next.js's
`test/e2e/middleware-redirects/test/index.test.ts` ("should redirect
to api route with locale").
- Updates to `tests/deploy.test.ts` assertions for the generated
worker-entry template strings.
* test(after-deploy): update worker-entry assertion after locale strip
The Pages Router worker entry now calls handleApiRoute(request,
apiLookupUrl, ctx) instead of using resolvedUrl directly, so this
generated-code assertion needs the new variable name.
Refs #1336 (item 3).
* fix(basepath): enforce scoping on rewrites/redirects/routes
Sub-issues addressed under #1333:
1. Rewrites/redirects/headers were firing on requests outside the
configured `basePath`. Threaded a `BasePathMatchState` through the
matchers and prod-server / deploy worker / dev plugin so default
rules (no `basePath: false` opt-out) only evaluate when the request
was under basePath, and opt-out rules only evaluate outside it.
5. Pages Router requests that landed outside basePath fell through to
internal route matching. After redirects and beforeFiles rewrites
run, the request now 404s when it was outside basePath and no
`basePath: false` rule rewrote it — matching Next.js's
`resolve-routes.ts:304-309`.
4. With rewrites no longer firing on out-of-basepath requests, the
downstream code that prepends basePath to destinations no longer
sees a stripped-but-already-internal pathname, eliminating the
`/docs/docs/...` doubling for the rewrite-driven cases.
App Router uses the same gating; `basePath: false` rules don't yet
fire there because `normalizeRscRequest` 404s out-of-basepath requests
before the matchers see them — flagged as follow-up in the rule
gating site. External rewrite proxying (sub-issue #2) and static
asset doubling (sub-issue #3, partly addressed by #1337/#1383) are
deferred.
Refs #1333
* test: update deploy.test signature assertions and format basepath tests
* review: address bonk nits — add inverse basePath:false tests and TODO comment
When no `app/global-error.tsx` (or route-level `error.tsx`) is defined,
vinext's runtime returned null from the error boundary fallback, which
caused the request to bubble up as a generic 500 instead of rendering
the styled default error page Next.js ships out of the box.
Port Next.js's built-in default global error component
(`packages/next/src/client/components/builtin/global-error.tsx`) to
`vinext/shims/default-global-error.tsx`, including the 32x32 warning
icon, the "This page couldn't load" heading, theme CSS, the Reload form
button, the Back button (client errors only), and the
`ERROR <digest>` footer (server errors only).
Wire this default in via a small module wrapper
(`server/default-global-error-module.ts`) so both the runtime error
boundary fallback (`app-fallback-renderer.ts`) and the route-level
client `<ErrorBoundary>` wrapper (via `app-page-element-builder.ts`)
fall back to it whenever `globalErrorModule` is null. User-defined
global error modules continue to take precedence.
Closes#1362
* fix(i18n): honor locale: false on rewrites/redirects
When a rewrite or redirect carries `locale: false`, Next.js leaves the
source pattern untouched so user-supplied `:locale` segments can capture
the locale prefix themselves. Without `locale: false`, Next.js prepends
an internal locale alternation so the rule matches every locale-prefixed
URL automatically.
Vinext previously ignored the flag entirely and never injected the
locale alternation, so:
- rewrites with `locale: false` on a source like
`/:locale/rewrite-files/:path*` failed because the underlying
matcher could not handle a non-trailing named param combined with a
trailing catch-all;
- plain rewrites/redirects with i18n configured silently failed to
match locale-prefixed paths.
This change:
1. Adds `locale?: false` (and `basePath?: false` for forward
compatibility) to NextRedirect/NextRewrite.
2. Fixes matchConfigPattern to use the regex branch whenever a pattern
has multiple named params and a catch-all.
3. Adds applyLocaleToRoutes mirroring Next.js's processRoutes — emits
`/:nextInternalLocale(en|sv|nl)/...` and (for redirects) per
default-locale literal variants while skipping rules flagged with
`locale: false`. Retains the original unprefixed source as a
fallback so default-locale requests that arrive without a prefix
still match (Next.js handles this upstream via request path
normalisation; doing it here avoids a server-wide change).
Ported tests from
test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts
Refs #1336 — items 2, 3, 4 remain as follow-ups.
* fix(i18n): address Bonk review feedback
- Keep the locale-capture variants emitted by applyLocaleToRoutes on the
redirect index fast path. `_LOCALE_STATIC_RE` now matches both the
optional `/:locale(en|fr)?/foo` user-written form and the mandatory
`/:nextInternalLocale(en|fr)/foo` form. Each entry records whether the
locale segment is optional; the no-locale-prefix branch only fires for
optional entries so mandatory variants still require a prefix.
- Thread `trailingSlash` through `applyLocaleToRoutes` so the root source
is preserved (`/:nextInternalLocale(en|fr)/`) when trailingSlash is
configured, matching Next.js processRoutes.
- Promote the dynamic import in next-config.ts to a static import — the
module is already loaded for isExternalUrl.
- Drop the unnecessary `new Set([...])` dedup and document that the
defaultLocales list will grow once domain-locale support lands.
* fix: make next/document default export a class
Next.js's `next/document` default export is `class Document extends
React.Component`. vinext's shim exported a function, so user
`_document.tsx` files that use the class-based form (the common Next.js
idiom: `class MyDocument extends Document`) ended up extending a
function. React refuses to call class constructors without `new`, which
caused SSR to throw "Class constructor MyDocument cannot be invoked
without 'new'" — surfacing in e2e as 500 / empty responses.
This is the actual root cause of the "async modules render empty pages"
report (issue #1361): the Next.js `test/e2e/async-modules` fixture's
`pages/_document.jsx` uses `class extends Document`, and once it crashes
the whole page renders empty. Top-level await itself propagates
correctly through Rolldown's static-import graph already.
Adds a class-based Document shim that mirrors Next.js's signature
(static `getInitialProps`, instance `render()`), plus production-build
e2e coverage for pages, `_app.tsx`, `getStaticProps`,
`getServerSideProps`, API routes, and class-based `_document.tsx`, all
using top-level `await`.
Closes#1361
Ports from Next.js: test/e2e/async-modules/index.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/async-modules/index.test.ts
* fix(next/document): align Document base class with Next.js's typed contract
Addresses Bonk review feedback on PR #1381:
- Export DocumentContext / DocumentInitialProps types matching Next.js,
so subclasses that delegate via `await Document.getInitialProps(ctx)`
receive typed objects. The runtime path is still a stub (the Pages
Router renderPage/defaultGetInitialProps chain is not wired up), but
the signatures pin the contract.
- Declare the default Document class export in next-shims.d.ts so
strict-mode consumers don't see `any` for `import Document from
"next/document"`.
- Add a class-extends regression test in tests/document.test.ts — this
is the contract that broke in issue #1361 (the original Next.js
`pages/_document.jsx` uses `class extends Document`).
- Switch the class generic default from `unknown` to `{}` to match
Next.js's `class Document<P = {}>`.
- Clarify App Router fixture comment: the upstream Next.js async-modules
suite is Pages-Router-only, so this is adapted, not ported verbatim.
Turbopack supports importing inline CSS via `data:text/css,...` and
`data:text/css+module,...` data URLs. Vite/Rolldown treats `data:`
specifiers as external, so the literal URL was reaching the bundled
output and Node/workerd refused to load it with
`ERR_UNKNOWN_MODULE_FORMAT: Unknown module format: text/css+module`,
breaking the entire build for any app that adopted the Turbopack-only
syntax (e.g. test/e2e/app-dir/css-modules-data-urls).
Add a pre-transform that rewrites these imports into synthetic
`\0vinext-data-css/<hash>.module.css` (or `.css`) specifiers. Vite's
existing CSS pipeline (LightningCSS, CSS modules) then handles
class-name hashing, JS export map generation, and stylesheet asset
emission. The decoded payload is cached by synthetic id so the same
data URL imported from RSC and client environments deduplicates the
way real CSS imports do.
Closes#1363
Middleware runs server-side, so importing `server-only` is semantically
correct. However, vinext bundles middleware into the SSR environment
via `virtual:vinext-server-entry`, and @vitejs/plugin-rsc's
`rsc:validate-imports` treats any non-RSC environment as a "client"
build, rejecting the bare specifier with:
'server-only' cannot be imported in client build ('ssr' environment):
imported by middleware.js
imported by virtual:vinext-server-entry
imported by virtual:vinext-app-ssr-entry
Next.js solves this with webpack `issuerLayer` rules: middleware sits
in the `neutralTarget` layer where `server-only` aliases to a no-op
while `client-only` still errors
(packages/next/src/build/webpack-config.ts).
Vite has no per-layer aliasing within an environment, so we mirror the
behavior with import-chain taint tracking: seed a Set with the
middleware entry path, propagate the taint along the resolveId graph,
and short-circuit `server-only` to the existing no-op shim when the
importer is tainted. The plugin uses `enforce: "pre"` so its resolveId
beats plugin-rsc's `order: "pre"` validate-imports filter on the bare
specifier.
Closes#1344
* fix(middleware): preserve query params on rewrite
When middleware (or a `next.config.js` rewrite) sends a request to a new
target, vinext was throwing away the original request's query string,
breaking `getServerSideProps`/route-handler `query` for any URL of the
form `/rewrite-me?key=value`.
Match Next.js's behaviour:
`Object.assign(parsedUrl.query, rewrittenParsedUrl.query)` — keep the
original query first, let the rewrite-target's query win on key
conflicts. Applied to:
- middleware rewrites (middleware-runtime.ts)
- beforeFiles / afterFiles / fallback config rewrites (prod-server.ts,
index.ts, deploy.ts)
Closes#1342
References:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/route-module.ts (Object.assign merge)
- https://github.com/vercel/next.js/blob/canary/test/e2e/edge-pages-support/index.test.ts (rewrite query expectations)
* test(query): document URL-encoded space normalization on rewrite merge
Addresses Bonk review observations on #1375:
- Add test confirming `%20` → `+` normalization through URLSearchParams
is intentional and lossless (downstream consumers re-parse).
- Drop unreachable `?? ""` fallback in mergeRewriteQuery; `String#split`
always yields a string.
* fix(middleware): redirect protocol — relative Location and x-nextjs-redirect
Match Next.js's edge adapter behaviour for middleware redirects:
1. Relativize the `Location` header for same-host redirects. Next.js only
emits absolute URLs when the redirect target is cross-origin; vinext
was always emitting `http://host/path` for same-host targets, which
broke deploy-suite parity tests.
2. Translate redirects to the `x-nextjs-redirect` soft-redirect protocol
when the request carries `x-nextjs-data: 1`. The client router uses
this header instead of a raw HTTP 3xx to avoid CORS issues on
cross-origin data fetches.
Both fixes live in `executeMiddleware`, so all four call sites
(prod-server, dev plugin, deploy worker, app-middleware) inherit them.
Refs #1334
* fix(middleware): thread isDataRequest through all callers
Address Bonk review. The previous commit checked `x-nextjs-data` on the
middleware request, but that header is in INTERNAL_HEADERS and is
stripped by `filterInternalHeaders` before any caller constructs the
middleware request -- so the soft-redirect branch was unreachable.
Make `isDataRequest` an explicit option on `ExecuteMiddlewareOptions`
and `ApplyAppMiddlewareOptions`, and capture it from the raw incoming
headers in each of the four entry points (dev plugin, prod server,
deploy worker, App Router RSC handler) before filtering. The generated
Pages Router runMiddleware now accepts a third `options` argument
carrying the flag through to the runtime helper.
Also: drop redundant lowercase `headers.delete("location")` -- the
Fetch spec makes Headers.delete case-insensitive.
Add a regression test ensuring a forged `x-nextjs-data: 1` request
header cannot trigger the soft-redirect path without the caller
explicitly opting in.
Refs #1334
vinext already exposes `<Link onNavigate>` and the matching `NavigateEvent`
contract (`{ url, preventDefault(), defaultPrevented }`), but the Next.js
deploy suite still fails the `replace history state for external links
with replace prop` case: when `<Link>` points at a truly external URL and
has `replace` set, vinext falls through to the browser's default click
navigation, which pushes a new entry instead of replacing the current one.
This mirrors `linkClicked` in
`.nextjs-ref/packages/next/src/client/link.tsx` for the external + replace
branch: call `e.preventDefault()` and `window.location.replace(href)` so
the entry is replaced rather than pushed.
Adds focused unit tests around the onNavigate contract: onClick + onNavigate
fire for internal clicks, `preventDefault()` cancels the navigation, and
onNavigate is skipped for modifier-key clicks, target=_blank, download
links, and external URLs (with and without `replace`).
Closes#1357
Next.js exposes `params` as `null` (effectively — via `params || null`
or `params ? await params : null` user-side) to route handlers and
`getServerSideProps`/`getStaticProps` on routes with no dynamic
segments. vinext was passing an empty object, which is truthy and
breaks user code that branches on `params`.
The fix computes a user-facing params value at each dispatch boundary
(`null` when `route.isDynamic === false`, otherwise the matched
object) while leaving internal bookkeeping — navigation context, query
merging, `useParams()` — on the empty-object shape that those
consumers expect.
References the Next.js test suite assertions:
- test/e2e/app-dir/app-routes/app-custom-routes.test.ts
"does not provide params to routes without dynamic parameters"
- test/e2e/edge-pages-support/index.test.ts
"should have correct query/params on index"
Closes#1350
* feat(pages-router): expose window.next.router on hydration
Promote next/router from a lazy dynamic import inside hydrate() to a
static side-effect import at the top of the generated client entry, so
window.next = { version, router, ... } is published before any test
harness or third-party script reads it.
Without this, the Next.js deploy test suite — which drives client-side
navigations via `browser.eval('next.router.push(...)')` — crashes with
`TypeError: Cannot read properties of undefined (reading 'router')` (~80
test failures across middleware-general, middleware-rewrites,
trailing-slashes, basepath, with-router, and ignore-invalid-popstateevent).
Mirrors Next.js's bootstrap order in
.nextjs-ref/packages/next/src/client/next.ts (line 5–13), which
statically imports the router from './' and sets window.next as a
top-level side effect before initialize/hydrate.
Closes#1329
* feat(router): expose state fields as live getters on Router singleton
window.next.router must expose `pathname`, `route`, `query`, `asPath`,
`basePath`, `locale`, `locales`, `defaultLocale`, `domainLocales`,
`isReady`, `isPreview`, and `isFallback` as live properties — not just
the navigation methods. The Next.js deploy test suite drives navigations
via `browser.eval('next.router.push(...)')` and then asserts on
`browser.eval('next.router.pathname')` to verify the navigation
completed; without the field getters the assertion reads undefined.
Mirrors Next.js's `singletonRouter` shape in
.nextjs-ref/packages/next/src/client/router.ts (lines 32-47), which
uses Object.defineProperty for urlPropertyFields so reads forward to
the active router state.
Strengthens the existing shims.test.ts coverage so future regressions
of the field surface fail at unit-test time.
Pages Router API routes did not receive the per-request Workers
`ExecutionContext`, so any `after()` (or other shim) call inside a
`pages/api/*` handler had no `ctx.waitUntil()` to keep the isolate
alive past the response. Thread `ctx` through `handleApiRoute`
→ `handlePagesApiRoute`, and wrap the user handler in
`runWithExecutionContext(ctx, ...)` so the ALS surfaces the ctx
for downstream `after()` calls.
Also pass a Node-shaped execution context from `vinext start` so
behavior is consistent across the dev / start / deploy server
paths.
Closes#1365
The `next/form` shim was rendering raw `action` strings without applying
basePath, and its Pages Router fallback bypassed the Router singleton —
meaning route events, HTML fetch, and scroll handling didn't fire on
form submission. Bring it in line with `next/link`:
- Prefix the form-level `action` prop with `__NEXT_ROUTER_BASEPATH`
(matches Next.js `addBasePath(actionProp)`). A submitter's `formAction`
is intentionally left alone, matching upstream.
- In Pages Router, delegate to `next/router` Router.push/replace so the
navigation flows through `performNavigation` like Link does. Keep the
pushState+popstate path as a recovery fallback only.
Closes#1355
When a page declared `getServerSideProps` (or `getStaticProps` /
`getStaticPaths`) as a local binding and re-exported it via
`export { getServerSideProps }`, the client-bundle transform overwrote
the `export { ... }` statement with `export const getServerSideProps =
undefined;`. The original local `const getServerSideProps = ...` was
left in place, producing two declarations of the same identifier in
module scope. Rolldown/OXC rejects this with `Identifier
'getServerSideProps' has already been declared`, breaking the entire
production build (and cascading to 48 test failures in Next.js's
`test/e2e/getserversideprops` suite).
Drop the export specifier without emitting a stub, matching Next.js's
`next-ssg-transform` behaviour. The now-unused local declaration becomes
dead code and is tree-shaken later.
Closes#1354
Two parity gaps with Next.js were causing edge runtime API routes to fail
with 500 errors:
1. Edge API handlers received a plain `Request` object, so accessing
`req.nextUrl.searchParams` (the idiomatic pattern in Next.js edge API
routes) threw at runtime. Next.js wraps the request in `NextRequest`
before invoking the handler (NextRequestHint in
next/src/server/web/adapter.ts).
2. Only `export const config = { runtime: 'edge' }` was recognised as an
edge runtime declaration. Next.js also accepts a bare
`export const runtime = 'edge'` and resolves the effective runtime as
`config.runtime ?? config.config?.runtime`
(next/src/build/analysis/get-page-static-info.ts).
Both fixes are applied in dev (`server/api-handler.ts`) and prod
(`server/pages-api-route.ts`) so the two paths stay in parity.
Refs #1338
`performNavigation()` and the `Router` singleton's `back`/`reload`/
`prefetch`/`beforePopState` methods referenced browser globals
(`window`, `document`) unconditionally. Invoking `router.push()` from a
Pages Router component during SSR or prerender (`VINEXT_PRERENDER=1`)
therefore crashed with `ReferenceError: window is not defined` and took
down the request pipeline.
Mirror Next.js's `ServerRouter` (packages/next/src/server/render.tsx),
where every navigation method synchronously throws "No router instance
found". The throw happens before any browser global is accessed, so the
failure surfaces as a normal render error instead of a ReferenceError —
and the documented message + docs link keeps userland error handling
intact.
Closes#1353
* fix(parallel-routes): resolve nested slot pages over default.tsx
When two sibling route groups occupy the same URL pattern (e.g.
`(group-a)/layout.tsx` next to `(group-b)/page.tsx`), the layout-only
sibling was dropped entirely because its pattern collided with the page
route, taking its parallel slots with it. Nested pages inside its
`@slot` directories were never materialized, so URLs that only the
catch-all slot could handle returned 404 / not-found instead of
rendering the matched slot page.
This change keeps the dropped layout-only routes as "ghost parents"
that participate in `discoverSlotSubRoutes`. Their slot sub-pages still
mint synthetic sub-routes (carrying the ghost's layout chain and slot
metadata), but the ghost itself is not added to the route table to
preserve the existing single-route-per-pattern invariant.
Closes#1339
Ported from Next.js tests:
- test/e2e/app-dir/parallel-routes-catchall-groups
* test(parallel-routes): cover shared-root URL of sibling route groups
Adds an e2e assertion that /parallel-group-catchall (the URL shared by
both (group-a) and (group-b)) renders (group-b)/page.tsx, not the
(group-a) ghost or its catch-all slot. Closes the coverage gap called
out in code review for the existing /foo and /bar tests.
* fix(app-router): parse interception route markers in route scanner
Refs #1364
* test(app-router): cover (.) and (...) markers, fix JSDoc typo
Addresses Bonk review feedback on #1398: add explicit coverage for the
remaining two interception marker shapes when used outside `@slot`, and
remove the stray quote in `isInterceptionMarkerDir`'s JSDoc.
When the route has no children page providing a title (parallel layout
that doesn't render `{children}`, or a parent that only has
`default.tsx`), allow the active parallel slot page to contribute the
primary <title>. Previously every slot was forced with
`contributesTitle: false`, which suppressed the slot title even when no
primary page title existed — producing an empty title or stale layout
title for these routes.
Slots keep the existing suppression when the matched page module already
provides a title, preserving the modal/sidebar case where the slot
metadata must not clobber the page title.
Refs #1341
Treat both build-time error shapes raised by `@vitejs/plugin-rsc` when a
server-action reference is missing from the manifest — dev's
`[vite-rsc] invalid server reference '<id>'` and prod's
`server reference not found '<id>'` — as Next.js action-not-found.
Previously only the dev-mode message was recognised, so production
builds (and apps with no server actions defined at all) returned 500
instead of Next.js' 404 + `x-nextjs-action-not-found: 1` response.
Both the fetch-action and progressive (no-JS) code paths benefit, since
the latter calls `isServerActionNotFoundError(error, null)` before it
has resolved an action id.
Refs #1340
When middleware or a next.config.js rewrite redirects an external URL
to an internal route target, `usePathname()` (and the navigation
context that backs it) was returning the internal rewrite destination
instead of the canonical/external path the user requested.
Preserve the pre-rewrite pathname as `canonicalPathname` and use it
when populating the App Router navigation context, while leaving
`cleanPathname` to drive internal route matching. This matches Next.js
behavior: test/e2e/app-dir/hooks/hooks.test.ts —
"should have the canonical url pathname on rewrite".
Closes#1348
* feat(cache): gate planner commits on explicit reuse proof
Runtime cache entries could enter browser commit planning as ordinary payloads even when the entry had no planner-visible reuse proof. That violated the #726 invariant that a cache hit is not semantic authority by itself.
The planner now receives a compact cache-entry proof decision through AppElements metadata and rejects missing or incompatible cache entries before route-topology commit approval. Cache-restored browser payloads synthesize the missing-proof rejection when metadata is absent, while fresh payloads keep the existing path.
Targeted tests cover proof projection, metadata transport, planner decisions, and the cached-payload lifecycle path.
* fix(cache): skip unproofed visited RSC cache restores
Visited App Router RSC snapshots were still eligible for replay even though production payloads do not yet carry cache-entry reuse proof. That let the new planner gate reject the cache path and turn soft restores into hard navigations.
Require decoded proof metadata before a payload is stored as a visited-response restore candidate, and update the real app-router navigation test to prove unproofed responses refetch without reloading. Also make cache-proof rejection-code parsing exhaustive at the type boundary.
* fix(cache): require explicit App Router payload origins
App Router navigation commits still carried cache-restore semantics as an optional boolean. A fresh default could hide a future restore path that forgot to request reuse proof, reopening the cache authority bug for visited responses.
The violated invariant is that cache origin is part of the payload contract, not a caller memory check.
Replace the boolean with a discriminated AppNavigationPayloadOrigin and thread it through browser entry, navigation controller, visible commit classification, and tests. Fresh payloads remain proof-optional; visited-cache payloads synthesize a missing-proof rejection and fail closed.
* docs(cache): document static layout proof parsing boundary
Runtime cache proof parsing currently accepts one proven reuse class: static layout. Future proof classes must update the parser before visited-cache responses can safely restore as commit-capable payloads.
The review also noted that the cache-restorable metadata predicate behaves as a narrowing boundary. Make that contract explicit as a TypeScript type guard without changing runtime behavior.
* fix(cache): keep accepted proof in rejection traces
Cache-proofed payloads that failed a later interception or root-boundary gate produced correct hard-navigation decisions, but the rejection trace lost the accepted proof fields. That made cache-proof diagnostics incomplete for payloads that passed proof validation and failed a later topology gate.
The root cause was that later rejection branches still used the pre-cache-proof trace fields after commitTraceFields had been computed.
Thread the enriched trace fields into those rejection decisions, document cache proof parser three-way semantics, narrow the cache-restorable metadata type guard precisely, and read the pending commit proof from the canonical top-level field.
* fix: support root-params dynamic getters during static prerendering and SSR
* fix: enforce strict parameter isolation for root params in prerendering and SSR
* fix: correct parameter mapping in multi-source resolver and clean up handleSsr fallback
* fix: use dedicated standalone ALS for root-params outside of unified request context
* fix: thread root params through generated app page dispatch
Normal App Router page requests computed root params in the typed handler, but the generated RSC entry did not forward them into page dispatch. SSR then received an empty root params scope for requests that should expose root params.
Destructure and forward rootParams in the generated entry, assert that template wiring, reuse the shared root-param picker, and align the scope helper API with the existing runWith overload pattern.
* fix(pages-router): preserve i18n client root navigation
Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.
Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.
* fix(pages-router): preserve i18n client root navigation
Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.
Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.
* fix(pages-router): validate next data parsing
* fix(pages-router): preserve link locale transitions
* fix(pages-router): preserve popstate locale fetch
* chore(pages-router): address locale review nits
* Enable static layout artifact reuse proof
* fix(cache): reject unproven static layout variants
Static layout reuse could previously accept public or internal candidate variant dimensions without proving that the current request had the same canonical dimensions. Public visibility only means the values are safe to expose, not that the layout artifact is invariant across routes, params, search state, or other dimensions.
Reject dimensioned static layout candidates until the API can compare candidate and current variants by canonical dimension source, name, privacy, and value hashes. Make cache outcome recording best-effort so telemetry failures cannot turn a cache hit into a miss or escape the read path.
* fix(cache): require route-budgeted layout reuse candidates
Static layout artifact reuse could still accept a raw buildCacheVariant result at the authority boundary. That allowed callers to bypass route-budget admission before receiving a canReuse true decision.
Require BuildCacheVariantWithRouteBudgetResult for createStaticLayoutArtifactReuseDecision and add a type-level regression that raw variants do not satisfy the authority input.
* chore(cache): clarify static layout proof traces
The approved review called out non-blocking ambiguity in trace fields and telemetry documentation. The proof behavior is unchanged, but the trace output and type regression are easier to reason about.
Sort unproven dimension sources defensively, rename artifact compatibility trace fallback fields, document raw app-page cache keys as internal-only, and make the raw-variant type regression explicit without a dead branch.
* chore(cache): dedupe static layout dimension sources
The unproven-dimension trace already sorted sources, but duplicate source labels could still make traces noisier than necessary. Use the existing sortedUnique helper so the source summary is deterministic and compact while dimensionCount remains exact.
* refactor(router): quarantine navigation runtime topology
* fix(router): restore manifest-backed interception runtime
App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.
The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.
Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.
* fix(router): narrow prerender RSC script parsing
The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.
Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.
* fix(router): address navigation runtime review
* fix(router): resolve navigation runtime review threads
* fix(pages-api): execute edge API routes with Fetch Request
Pages API routes with config.runtime set to edge were still executed through the Node-style req/res adapter. That gives user handlers a plain headers object and turns valid Edge API code that returns a Response into a 500.
Detect edge and experimental-edge API route modules before Node body parsing, pass the Fetch Request through, and forward the returned Response. The dev Node bridge mirrors the generated Web Request path and preserves Set-Cookie response headers.
Regression coverage ports the relevant Next.js edge-async-local-storage contract for Fetch Request execution, concurrent AsyncLocalStorage isolation, and the dev bridge.
* fix(pages-api): stream edge API responses in dev
* fix(pages-api): handle closed dev edge streams
* fix(pages-api): stream dev edge request bodies
* fix(pages-api): validate dev edge forwarded protocol
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link.
Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace.
Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes.