Skip to content

fix(nextjs): Lazy-load the Pages Router module so App Router apps do not bundle the Pages Router runtime - #24033

Open
AlbertoMihai98 wants to merge 1 commit into
getsentry:developfrom
AlbertoMihai98:fix/nextjs-lazy-next-router
Open

fix(nextjs): Lazy-load the Pages Router module so App Router apps do not bundle the Pages Router runtime#24033
AlbertoMihai98 wants to merge 1 commit into
getsentry:developfrom
AlbertoMihai98:fix/nextjs-lazy-next-router

Conversation

@AlbertoMihai98

Copy link
Copy Markdown

Before submitting a pull request, please take a look at our
Contributing guidelines and verify:

  • If you've added code that should be tested, please add tests.
  • Ensure your code lints and the test suite passes (yarn lint) & (yarn test).
  • Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked.

Closes #24032

What

client/routing/pagesRouterRoutingInstrumentation.ts imported next/router statically and resolved the CJS/ESM interop at module scope. next/router is the whole Pages Router client runtime (Router class, path-to-regexp, route loader, script.js, ...), it is reached statically from the client entry (client/index.ts -> browserTracingIntegration -> nextRoutingInstrumentation -> this file), and the app/pages decision is made at runtime - so every App Router app shipped ~87 KB raw / ~36 KB gzip of Pages Router code it can never execute, and no bundler could remove it (next declares no sideEffects; with or without __SENTRY_TRACING__).

This PR:

  • keeps import type RouterImport from 'next/router' for the types and loads the module on demand (loadNextRouter()), only from pagesRouterInstrumentNavigation - the single place that needs it (Router.events.on('routeChangeStart', ...)). The pageload instrumentation is unchanged: it reads __NEXT_DATA__ and __BUILD_MANIFEST only, so the pageload span is still started synchronously in afterAllSetup.
  • keeps the existing CJS/ESM interop, one level up (namespace -> .default -> .default.default), and logs a DEBUG_BUILD warning if the import ever rejects.
  • adapts pagesRouterInstrumentation.test.ts (await vi.dynamicImportSettled() after pagesRouterInstrumentNavigation, same idiom as sveltekit/test/client/browserTracingIntegration.test.ts) and adds a test that the listener is registered exactly once, only after the import has settled, and that the pageload path never touches the router.
  • adds test/clientEntryBundlerGraph.test.ts, mirroring serverEntryBundlerGraph.test.ts: a child process requires the built CJS client entry and fails if next/router or next/dist/client/router is in require.cache (with a positive control that the Pages Router instrumentation itself was loaded). Reintroducing the static import makes it fail with the two module paths listed; I ran that mutation before opening this.

The size-limit entry for @sentry/nextjs (client) cannot see this class of regression because it has ignore: ['next/router', 'next/constants']; the graph test above is the gate instead.

Measurements

Same app, same instrument at both ends (next build --experimental-analyze + a chunk-composition script), patching the installed build of the published SDK with exactly this change.

App Router app (Next 16.3.3, Turbopack, __SENTRY_TRACING__ compiled to false)

Sentry client chunk (raw) of which next (Pages Router runtime)
@sentry/nextjs 10.72.0 as published 168.4 KB 87.2 KB / 35.6 KB gzip (52 modules)
this change 82 KB 0 - it lands in an async chunk (87 KB) the app never requests

Pages Router app (minimal: /, /about via <Link>, /redirect doing router.replace('/about') from a mount effect; tracesSampleRate: 1, beforeSendTransaction records every transaction on window.__txns)

Built with @sentry/nextjs 10.73.0 as published, then with this change applied to the published ESM file (the fork's own build targets v11 exports and cannot be installed against the 10.x registry graph); served with next start and driven with Chrome DevTools.

variant Turbopack: script requests on / pageload webpack: script requests on / pageload pageload span <Link> navigation span mount-time router.replace() span
published (static import) 17 11 yes yes yes
import('next/router') (public shim) 18 - a new 151-byte chunk t.exports=o.r(26990) requested on every pageload 12 - a new 99-byte chunk yes yes yes
import('next/dist/client/router') (this PR) 17 - no new chunk in the build 11 - no new chunk in the build yes yes yes

The shim (next/router.js = module.exports = require('./dist/client/router')) is not part of a Pages Router app's initial chunks unless the app itself imports next/router on that page, so both bundlers turn import('next/router') into a real, tiny async chunk; the module it re-exports is already loaded by the Pages Router runtime, so import('next/dist/client/router') costs nothing at runtime and settles on the next microtask. The three span rows carry the exact Pages Router origins (auto.pageload.nextjs.pages_router_instrumentation / auto.navigation.nextjs.pages_router_instrumentation), read from a beforeSendTransaction hook.

Notes for reviewers

  • window.next.router.events (dropping next/router altogether) was considered and rejected: Next marks instance.events as "never documented ... remove the following major version", and the instance only exists after createRouter().
  • Making the whole Pages Router module lazy (from nextRoutingInstrumentation.ts) was rejected too: it would make the pageload span asynchronous, and afterAllSetup relies on it existing before browserTracingIntegrationInstance.afterAllSetup(client) continues.
  • feat(nextjs): Register a route provider outside the tracing integration #23552 touches the same file (route provider); happy to rebase onto whichever lands first.
  • Could this be backported to v10? The chain and the cost are identical there (10.73.0).

Gates run locally on the branch: yarn build, yarn test:unit (58 files / 997 tests), yarn lint, yarn lint:types, yarn circularDepCheck, yarn lint:es-compatibility, oxfmt --check on the touched files - all green.

🤖 Generated with Claude Code

…not bundle the Pages Router runtime

`client/routing/pagesRouterRoutingInstrumentation.ts` imported `next/router` statically and
resolved its CJS/ESM interop at module scope. That module is the whole Pages Router client
runtime (the `Router` class, path-to-regexp, the route loader, script.js, ...), it is reached
statically from the client entry, and whether an app uses the Pages Router is only known at
runtime - so every App Router app shipped ~87 KB raw / ~36 KB gzip of code it can never
execute, and no bundler could remove it (`next` declares no `sideEffects`).

The router is now imported on demand, only from `pagesRouterInstrumentNavigation`, the single
place that needs it; the pageload instrumentation is unchanged and still synchronous. The
import targets `next/dist/client/router` rather than the `next/router` shim: the shim is not
part of a Pages Router app's initial chunks and became a tiny extra chunk request on every
pageload (151 bytes on Turbopack, 99 on webpack), while the module itself is already loaded
by the framework runtime, so importing it directly adds no request.

Tests: the navigation tests await `vi.dynamicImportSettled()`; a new test pins that the
listener is registered exactly once and only after the import settles, and that the pageload
path never touches the router; `test/clientEntryBundlerGraph.test.ts` requires the built CJS
client entry in a child process and fails if `next/router` or `next/dist/client/router` is in
the module cache (with a positive control on the instrumentation module itself).

Measured on an App Router app (Next 16.3.3, Turbopack): Sentry client chunk 168.4 KB -> 82 KB
raw; the Pages Router runtime lands in an async chunk the app never requests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@AlbertoMihai98
AlbertoMihai98 requested a review from a team as a code owner September 3, 2026 21:58
@AlbertoMihai98
AlbertoMihai98 requested review from mydea and s1gr1d and removed request for a team September 3, 2026 21:58
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.

@sentry/nextjs bundles the whole Pages Router runtime (next/router, ~87 KB raw / 36 KB gzip) into every App Router app

1 participant