From a8e2d0e34090a88670343d89f58a333f1bd84614 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 24 Aug 2026 15:38:24 -0400 Subject: [PATCH 1/6] feat(nextjs): Register a route provider outside the tracing integration Both routers already ship a pure matcher: the App Router has the build-time route manifest behind `maybeParameterizeRoute`, and the Pages Router matches against `__BUILD_MANIFEST.sortedPages`. Neither was reachable from anywhere except the pageload and navigation instrumentation. Registered from `init()` rather than `browserTracingIntegration`, because both manifests are on the global object before `Sentry.init` runs. Route parameterization no longer depends on tracing being enabled, so `bfcacheMetrics` resolves a parameterized route with `browserTracingIntegration` absent. The two manifests want the pathname differently, since App Router routes are generated with `basePath` baked in while Next strips it internally for the Pages Router, so the provider normalizes per manifest. --- packages/nextjs/src/client/index.ts | 7 ++ .../appRouterRoutingInstrumentation.ts | 19 ++--- .../pagesRouterNavigationInstrumentation.ts | 56 +-------------- .../pagesRouterRoutingInstrumentation.ts | 60 ++++++++++++++++ .../src/client/routing/parameterization.ts | 24 +++++++ .../src/client/routing/routeProvider.ts | 26 +++++++ .../nextjs/test/client/routeProvider.test.ts | 70 +++++++++++++++++++ 7 files changed, 194 insertions(+), 68 deletions(-) create mode 100644 packages/nextjs/src/client/routing/routeProvider.ts create mode 100644 packages/nextjs/test/client/routeProvider.test.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 828a4f0a541a..ace00d164dfb 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -3,6 +3,7 @@ /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import { setRouteProvider } from '@sentry/core/browser'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -12,6 +13,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -79,6 +81,11 @@ export function init(options: BrowserOptions): Client | undefined { const client = reactInit(opts); + // Registered here rather than from `browserTracingIntegration` so route parameterization does not + // depend on tracing: the route manifests are injected at build time, so anything that needs a route + // name (bfcache metrics, web vitals) can resolve one even with tracing disabled. + setRouteProvider(createNextRouteProvider(), client); + const filterNextRedirectError: EventProcessor = (event, hint) => isRedirectNavigationError(hint?.originalException) || event.exception?.values?.[0]?.value === 'NEXT_REDIRECT' ? null diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index eb3edbf3eced..5eedd49b3f24 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -8,13 +8,14 @@ import { filterCollectedUrl, timestampInSeconds, } from '@sentry/core'; +import { resolveCurrentRoute, resolveRoute } from '@sentry/core/browser'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { stripTrailingSlash } from './parameterization'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -24,14 +25,6 @@ import { } from '@sentry/conventions/attributes'; import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; -/** - * Strips trailing slash from a pathname, unless it's the root path. - * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. - */ -function stripTrailingSlash(pathname: string): string { - return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; -} - function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ [URL_PATH]: urlPath, @@ -103,7 +96,7 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); startBrowserTracingPageLoadSpan(client, { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), @@ -158,7 +151,7 @@ export function appRouterInstrumentNavigation(client: Client): void { const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); - const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const parameterizedPathname = resolveRoute(normalizedHref, client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? @@ -198,7 +191,7 @@ export function appRouterInstrumentNavigation(client: Client): void { WINDOW.addEventListener('popstate', () => { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); @@ -306,7 +299,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe const normalizedHref = basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href; const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); - const parameterizedPathname = maybeParameterizeRoute(transactionName); + const parameterizedPathname = resolveRoute(transactionName, client); currentNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, diff --git a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts index b94eadc5c134..a625435263d1 100644 --- a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts @@ -9,6 +9,7 @@ import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } from '@sent import RouterImport from 'next/router'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION } from '@sentry/conventions/op'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; // next/router v10 is CJS // @@ -59,58 +60,3 @@ export function pagesRouterInstrumentNavigation(client: Client): void { ); }); } - -function getNextRouteFromPathname(pathname: string): string | undefined { - const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; - - // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here - if (!pageRoutes) { - return; - } - - return pageRoutes.find(route => { - const routeRegExp = convertNextRouteToRegExp(route); - return pathname.match(routeRegExp); - }); -} - -/** - * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). - * - * In general this involves replacing any instances of square brackets in a route with a wildcard: - * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ - * - * Some additional edgecases need to be considered: - * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or - * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". - * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). - * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). - * - * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` - */ -function convertNextRouteToRegExp(route: string): RegExp { - // We can assume a route is at least "/". - const routeParts = route.split('/'); - - let optionalCatchallWildcardRegex = ''; - if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { - // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing - // slash that would come before it if we didn't pop it. - routeParts.pop(); - optionalCatchallWildcardRegex = '(?:/(.+?))?'; - } - - const rejoinedRouteParts = routeParts - .map( - routePart => - routePart - .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard - .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards - ) - .join('/'); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input - return new RegExp( - `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end - ); -} diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 8cbf116334e4..7a4ebaad3877 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -122,3 +122,63 @@ export function pagesRouterInstrumentPageLoad(client: Client): void { { sentryTrace, baggage }, ); } + +/** + * Matches a pathname against the Pages Router build manifest, e.g. `/users/1` -> `/users/[id]`. + * + * Expects a pathname without `basePath`, which is what Next reports internally. + */ +export function getNextRouteFromPathname(pathname: string): string | undefined { + const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; + + // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here + if (!pageRoutes) { + return; + } + + return pageRoutes.find(route => { + const routeRegExp = convertNextRouteToRegExp(route); + return pathname.match(routeRegExp); + }); +} + +/** + * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). + * + * In general this involves replacing any instances of square brackets in a route with a wildcard: + * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ + * + * Some additional edgecases need to be considered: + * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or + * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". + * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). + * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). + * + * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` + */ +function convertNextRouteToRegExp(route: string): RegExp { + // We can assume a route is at least "/". + const routeParts = route.split('/'); + + let optionalCatchallWildcardRegex = ''; + if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { + // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing + // slash that would come before it if we didn't pop it. + routeParts.pop(); + optionalCatchallWildcardRegex = '(?:/(.+?))?'; + } + + const rejoinedRouteParts = routeParts + .map( + routePart => + routePart + .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard + .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards + ) + .join('/'); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input + return new RegExp( + `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end + ); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index da25c1beb840..567bddacdc75 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -12,6 +12,30 @@ let cachedManifestString: string | undefined = undefined; const compiledRegexCache: Map = new Map(); const routeResultCache: Map = new Map(); +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + +/** + * Strips trailing slash from a pathname, unless it's the root path. + * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. + */ +export function stripTrailingSlash(pathname: string): string { + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +/** + * Removes the configured `basePath` from a pathname. + * + * App Router routes are generated with `basePath` baked in, but Next strips it internally for the + * Pages Router, so `__BUILD_MANIFEST.sortedPages` holds routes without it. + */ +export function stripBasePath(pathname: string): string { + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + + return basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) || '/' : pathname; +} + // Specificity ranks for a single route segment, from most to least specific. `END` is the rank of // the position just past the last segment of a route, so that a route which stops is compared // against whatever the longer route continues with. diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts new file mode 100644 index 000000000000..e969815eb8c2 --- /dev/null +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -0,0 +1,26 @@ +import type { RouteProvider } from '@sentry/core/browser'; +import { createUrlRouteProvider } from '@sentry/core/browser'; +import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; + +/** + * Resolves a URL against whichever router manifest the app ships. + * + * App Router routes are generated with `basePath` baked in, which is what `location.pathname` gives + * us; Next strips it internally for the Pages Router, so the fallback strips it too. + */ +function resolveNextRoute(url: URL): string | undefined { + const pathname = stripTrailingSlash(url.pathname); + + return maybeParameterizeRoute(pathname) ?? getNextRouteFromPathname(stripBasePath(pathname)); +} + +/** + * A route provider backed by the route manifests Next.js injects at build time. + * + * Both manifests are on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createNextRouteProvider(): RouteProvider { + return createUrlRouteProvider(resolveNextRoute); +} diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..be2624bd4219 --- /dev/null +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -0,0 +1,70 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/core/browser'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, setCurrentClient } from '@sentry/react'; +import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; + +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; + +let originalDocument: unknown; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], + isrRoutes: [], +}); + +function makeClient(): BrowserClient { + // Deliberately no integrations at all, so nothing tracing-related can be supplying the route. + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('createNextRouteProvider', () => { + beforeEach(() => { + globalWithManifest._sentryRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `getLocationHref()` reads `document.location.href`; the listener stubs are only here so + // `client.init()` does not trip over the stand-in. + (GLOBAL_OBJ as { document?: unknown }).document = { + location: { href: 'https://example.com/users/42' }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + }); + + afterEach(() => { + delete globalWithManifest._sentryRouteManifest; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('resolves the current route without a tracing integration', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(client.getIntegrationByName('BrowserTracing')).toBeUndefined(); + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/nope/deep', client)).toBeUndefined(); + }); +}); From bd05b82dafacd30ece2154fb688d2add22f29760 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 12:10:20 -0400 Subject: [PATCH 2/6] ref(nextjs): Drop the unused global alias from the Pages Router navigation module --- .../client/routing/pagesRouterNavigationInstrumentation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts index a625435263d1..44d842b6193a 100644 --- a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts @@ -5,7 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } from '@sentry/react'; +import { getAbsoluteUrl, startBrowserTracingNavigationSpan } from '@sentry/react'; import RouterImport from 'next/router'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION } from '@sentry/conventions/op'; @@ -18,8 +18,6 @@ const Router: typeof RouterImport = RouterImport.events ? RouterImport : (RouterImport as unknown as { default: typeof RouterImport }).default; -const globalObject = WINDOW; - /** * Instruments the Next.js pages router for navigation. * Only supported for client side routing. Works for Next >= 10. From 8eae21f4a41236237ff0a7364f6d9c6eb8c24444 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 12:10:20 -0400 Subject: [PATCH 3/6] fix(nextjs): Register the route provider before the pageload span is named The provider was registered after `reactInit` returned, but the pageload span is named in `browserTracingIntegration`'s `afterAllSetup`, which runs inside it. So every App Router pageload lost its parameterized name. Registering it from a default integration's `setup` runs it before any `afterAllSetup` while still not depending on tracing. --- packages/nextjs/src/client/index.ts | 9 ++---- .../src/client/routing/routeProvider.ts | 18 ++++++++++- .../appRouterRoutingInstrumentation.test.ts | 8 ++++- .../nextjs/test/client/routeProvider.test.ts | 30 ++++++++++++++++++- packages/nextjs/test/clientSdk.test.ts | 24 ++++++++++++++- 5 files changed, 78 insertions(+), 11 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index ace00d164dfb..5b49cc919482 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -3,7 +3,6 @@ /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; -import { setRouteProvider } from '@sentry/core/browser'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -13,7 +12,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; -import { createNextRouteProvider } from './routing/routeProvider'; +import { nextjsRouteProviderIntegration } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -81,11 +80,6 @@ export function init(options: BrowserOptions): Client | undefined { const client = reactInit(opts); - // Registered here rather than from `browserTracingIntegration` so route parameterization does not - // depend on tracing: the route manifests are injected at build time, so anything that needs a route - // name (bfcache metrics, web vitals) can resolve one even with tracing disabled. - setRouteProvider(createNextRouteProvider(), client); - const filterNextRedirectError: EventProcessor = (event, hint) => isRedirectNavigationError(hint?.originalException) || event.exception?.values?.[0]?.value === 'NEXT_REDIRECT' ? null @@ -112,6 +106,7 @@ export function init(options: BrowserOptions): Client | undefined { function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); + customDefaultIntegrations.push(nextjsRouteProviderIntegration()); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", // in which case everything inside will get tree-shaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts index e969815eb8c2..149713220c63 100644 --- a/packages/nextjs/src/client/routing/routeProvider.ts +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -1,5 +1,6 @@ +import { defineIntegration } from '@sentry/core'; import type { RouteProvider } from '@sentry/core/browser'; -import { createUrlRouteProvider } from '@sentry/core/browser'; +import { createUrlRouteProvider, setRouteProvider } from '@sentry/core/browser'; import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; @@ -24,3 +25,18 @@ function resolveNextRoute(url: URL): string | undefined { export function createNextRouteProvider(): RouteProvider { return createUrlRouteProvider(resolveNextRoute); } + +/** + * Registers the Next.js route provider. + * + * An integration rather than part of `browserTracingIntegration` so route parameterization does not depend + * on tracing: the route manifests are injected at build time, so anything that needs a route name (bfcache + * metrics, web vitals) can resolve one even with tracing disabled. Registered in `setup` because the pageload + * span is named in `browserTracingIntegration`'s `afterAllSetup`. + */ +export const nextjsRouteProviderIntegration = defineIntegration(() => ({ + name: 'NextjsRouteProvider', + setup(client) { + setRouteProvider(createNextRouteProvider(), client); + }, +})); diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index e54e9867cc89..f0de6b794df2 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -11,11 +11,13 @@ import '@sentry/core'; import '@sentry/react'; import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; +import type * as RouteProvider from '../../src/client/routing/routeProvider'; import type { RouteManifest } from '../../src/config/manifest/types'; type Core = typeof SentryCore; type React = typeof SentryReact; type Instrumentation = typeof AppRouterInstrumentation; +type RouteProviderModule = typeof RouteProvider; interface NextRouter { back: () => void; @@ -61,6 +63,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ const core: Core = await import('@sentry/core'); const react: React = await import('@sentry/react'); const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation'); + const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider'); const client = new react.BrowserClient({ dsn: 'http://examplePublicKey@localhost/0', @@ -68,7 +71,10 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ stackParser: () => [], tracesSampleRate: 1, traceLifecycle, - integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + integrations: [ + routeProvider.nextjsRouteProviderIntegration(), + react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false }), + ], }); core.setCurrentClient(client); client.init(); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts index be2624bd4219..4d4677306647 100644 --- a/packages/nextjs/test/client/routeProvider.test.ts +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BrowserClient, setCurrentClient } from '@sentry/react'; import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; -const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest?: string; + _sentryBasePath?: string; + __BUILD_MANIFEST?: { sortedPages?: string[] }; +}; let originalDocument: unknown; @@ -43,6 +47,8 @@ describe('createNextRouteProvider', () => { afterEach(() => { delete globalWithManifest._sentryRouteManifest; + delete globalWithManifest._sentryBasePath; + delete globalWithManifest.__BUILD_MANIFEST; (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; }); @@ -67,4 +73,26 @@ describe('createNextRouteProvider', () => { expect(resolveRoute('https://example.com/nope/deep', client)).toBeUndefined(); }); + + describe('Pages Router', () => { + beforeEach(() => { + globalWithManifest.__BUILD_MANIFEST = { sortedPages: ['/', '/_app', '/_error', '/posts/[slug]'] }; + }); + + it('falls back to the Pages Router manifest when the App Router manifest has no match', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/posts/hello', client)).toBe('/posts/[slug]'); + }); + + it('strips `basePath` before matching, since Pages Router routes are generated without it', () => { + globalWithManifest._sentryBasePath = '/docs'; + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/docs/posts/hello', client)).toBe('/posts/[slug]'); + expect(resolveRoute('https://example.com/docs', client)).toBe('/'); + }); + }); }); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index a4cf4869102f..9e41027005a6 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, SentryNonRecordingSpan } from '@sentry/core'; +import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -188,6 +188,28 @@ describe('Client init()', () => { delete globalThis.__SENTRY_TRACING__; }); + it('names the pageload span after the parameterized route', () => { + const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; + globalWithManifest._sentryRouteManifest = JSON.stringify({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }); + + init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); + + expect(spanToJSON(SentryReact.getActiveSpan()!)).toMatchObject({ + name: '/', + attributes: { + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/', + }, + }); + + delete globalWithManifest._sentryRouteManifest; + }); + it("doesn't run Next.js router instrumentation for bot user agents", () => { Object.defineProperty(WINDOW, 'navigator', { value: { From 42114511ca6d64188d5d707d331b4f940af13ada Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 14:29:39 -0400 Subject: [PATCH 4/6] ref(nextjs): Only require the pathname in the route provider --- packages/nextjs/src/client/routing/routeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts index 149713220c63..f714150b0fc8 100644 --- a/packages/nextjs/src/client/routing/routeProvider.ts +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -10,7 +10,7 @@ import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; * App Router routes are generated with `basePath` baked in, which is what `location.pathname` gives * us; Next strips it internally for the Pages Router, so the fallback strips it too. */ -function resolveNextRoute(url: URL): string | undefined { +function resolveNextRoute(url: { pathname: string }): string | undefined { const pathname = stripTrailingSlash(url.pathname); return maybeParameterizeRoute(pathname) ?? getNextRouteFromPathname(stripBasePath(pathname)); From c0d26afeffabc786df92251a487db0d83b28b129 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 14:35:36 -0400 Subject: [PATCH 5/6] ref(nextjs): Import the route provider API from `@sentry/react` --- .../src/client/routing/appRouterRoutingInstrumentation.ts | 3 ++- packages/nextjs/src/client/routing/routeProvider.ts | 4 ++-- packages/nextjs/test/client/routeProvider.test.ts | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 5eedd49b3f24..cd0d0980ade8 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -8,12 +8,13 @@ import { filterCollectedUrl, timestampInSeconds, } from '@sentry/core'; -import { resolveCurrentRoute, resolveRoute } from '@sentry/core/browser'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, + resolveCurrentRoute, + resolveRoute, } from '@sentry/react'; import { stripTrailingSlash } from './parameterization'; import { diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts index f714150b0fc8..18184ac2014e 100644 --- a/packages/nextjs/src/client/routing/routeProvider.ts +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -1,6 +1,6 @@ import { defineIntegration } from '@sentry/core'; -import type { RouteProvider } from '@sentry/core/browser'; -import { createUrlRouteProvider, setRouteProvider } from '@sentry/core/browser'; +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider, setRouteProvider } from '@sentry/react'; import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts index 4d4677306647..90721c503867 100644 --- a/packages/nextjs/test/client/routeProvider.test.ts +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -1,7 +1,6 @@ import { GLOBAL_OBJ } from '@sentry/core'; -import { resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/core/browser'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BrowserClient, setCurrentClient } from '@sentry/react'; +import { BrowserClient, setCurrentClient, resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/react'; import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { From d20fbbef94dc1f5451e272ab53fa1c79d4d6453a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 15:56:51 -0400 Subject: [PATCH 6/6] ref(nextjs): Pass the route provider as the `routeProvider` option --- packages/nextjs/src/client/index.ts | 6 ++++-- .../nextjs/src/client/routing/routeProvider.ts | 18 +----------------- .../appRouterRoutingInstrumentation.test.ts | 6 ++---- packages/nextjs/test/clientSdk.test.ts | 8 ++++++++ 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 5b49cc919482..37763c0e0607 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -12,7 +12,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; -import { nextjsRouteProviderIntegration } from './routing/routeProvider'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -66,6 +66,9 @@ export function init(options: BrowserOptions): Client | undefined { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, + // Both route manifests are injected at build time, so route parameterization works from `init` on, + // including for the pageload span and with tracing disabled. + routeProvider: createNextRouteProvider(), ...options, } satisfies BrowserOptions; @@ -106,7 +109,6 @@ export function init(options: BrowserOptions): Client | undefined { function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); - customDefaultIntegrations.push(nextjsRouteProviderIntegration()); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", // in which case everything inside will get tree-shaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts index 18184ac2014e..d6ef74b8698c 100644 --- a/packages/nextjs/src/client/routing/routeProvider.ts +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -1,6 +1,5 @@ -import { defineIntegration } from '@sentry/core'; import type { RouteProvider } from '@sentry/react'; -import { createUrlRouteProvider, setRouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; @@ -25,18 +24,3 @@ function resolveNextRoute(url: { pathname: string }): string | undefined { export function createNextRouteProvider(): RouteProvider { return createUrlRouteProvider(resolveNextRoute); } - -/** - * Registers the Next.js route provider. - * - * An integration rather than part of `browserTracingIntegration` so route parameterization does not depend - * on tracing: the route manifests are injected at build time, so anything that needs a route name (bfcache - * metrics, web vitals) can resolve one even with tracing disabled. Registered in `setup` because the pageload - * span is named in `browserTracingIntegration`'s `afterAllSetup`. - */ -export const nextjsRouteProviderIntegration = defineIntegration(() => ({ - name: 'NextjsRouteProvider', - setup(client) { - setRouteProvider(createNextRouteProvider(), client); - }, -})); diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index f0de6b794df2..7c7701be40f6 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -71,10 +71,8 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ stackParser: () => [], tracesSampleRate: 1, traceLifecycle, - integrations: [ - routeProvider.nextjsRouteProviderIntegration(), - react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false }), - ], + routeProvider: routeProvider.createNextRouteProvider(), + integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], }); core.setCurrentClient(client); client.init(); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 9e41027005a6..f9367080f699 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -210,6 +210,14 @@ describe('Client init()', () => { delete globalWithManifest._sentryRouteManifest; }); + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + + init({ dsn: TEST_DSN, routeProvider }); + + expect(reactInit).toHaveBeenCalledWith(expect.objectContaining({ routeProvider })); + }); + it("doesn't run Next.js router instrumentation for bot user agents", () => { Object.defineProperty(WINDOW, 'navigator', { value: {