diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc
new file mode 100644
index 000000000000..a3160f4de175
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc
@@ -0,0 +1,4 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
+public-hoist-pattern[]=*import-in-the-middle*
+public-hoist-pattern[]=*require-in-the-middle*
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx
new file mode 100644
index 000000000000..477881513d3f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx
@@ -0,0 +1,3 @@
+export default function CatchAllPage() {
+ return
Catch-all page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx
new file mode 100644
index 000000000000..c8f9cee0b787
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx
new file mode 100644
index 000000000000..b55e84109d2c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx
@@ -0,0 +1,20 @@
+import Link from 'next/link';
+
+export default function Page() {
+ return (
+
+
Next 16 trailing slash test app
+
+ -
+ Static Page
+
+ -
+ Parameterized
+
+ -
+ Parameterized Static
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx
new file mode 100644
index 000000000000..e3a3dafe45f2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx
@@ -0,0 +1,3 @@
+export default function ParameterizedPage() {
+ return Dynamic parameterized page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx
new file mode 100644
index 000000000000..4cb7f5887fc4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx
@@ -0,0 +1,3 @@
+export default function ParameterizedStaticPage() {
+ return Parameterized static page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx
new file mode 100644
index 000000000000..16ef0482d53b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx
@@ -0,0 +1,3 @@
+export default function StaticPage() {
+ return Static page
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts
new file mode 100644
index 000000000000..4870c64e7959
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
+
+export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts
new file mode 100644
index 000000000000..964f937c439a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts
@@ -0,0 +1,13 @@
+import * as Sentry from '@sentry/nextjs';
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ await import('./sentry.server.config');
+ }
+
+ if (process.env.NEXT_RUNTIME === 'edge') {
+ await import('./sentry.edge.config');
+ }
+}
+
+export const onRequestError = Sentry.captureRequestError;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts
new file mode 100644
index 000000000000..80946b61ec01
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts
@@ -0,0 +1,10 @@
+import { withSentryConfig } from '@sentry/nextjs';
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ trailingSlash: true,
+};
+
+export default withSentryConfig(nextConfig, {
+ silent: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json
new file mode 100644
index 000000000000..c097fd9e2b98
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "nextjs-16-trailing-slash",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
+ "start": "next start",
+ "test:prod": "TEST_ENV=production playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
+ "test:assert": "pnpm test:prod"
+ },
+ "dependencies": {
+ "@sentry/nextjs": "latest || *",
+ "@sentry/core": "latest || *",
+ "import-in-the-middle": "^2",
+ "next": "16.1.5",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "require-in-the-middle": "^8"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "sentryTest": {
+ "variants": [
+ {
+ "build-command": "pnpm test:build-latest",
+ "label": "nextjs-16-trailing-slash (latest, turbopack)"
+ }
+ ]
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs
new file mode 100644
index 000000000000..38548e975851
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs
@@ -0,0 +1,25 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+const testEnv = process.env.TEST_ENV;
+
+if (!testEnv) {
+ throw new Error('No test env defined');
+}
+
+const getStartCommand = () => {
+ if (testEnv === 'development') {
+ return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs';
+ }
+
+ if (testEnv === 'production') {
+ return 'pnpm next start -p 3030';
+ }
+
+ throw new Error(`Unknown test env: ${testEnv}`);
+};
+
+const config = getPlaywrightConfig({
+ startCommand: getStartCommand(),
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts
new file mode 100644
index 000000000000..85bd765c9c44
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts
new file mode 100644
index 000000000000..85bd765c9c44
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs
new file mode 100644
index 000000000000..31efe3256f8b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs
@@ -0,0 +1,14 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')));
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nextjs-16-trailing-slash',
+ envelopeDumpPath: path.join(
+ process.cwd(),
+ `event-dumps/next-16-trailing-slash-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
+ ),
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts
new file mode 100644
index 000000000000..cfdfe12d0c27
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts
@@ -0,0 +1,147 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+// These tests verify that pageload transactions are correctly named when
+// trailingSlash: true is enabled in next.config.ts, even when a catch-all
+// route exists. See: https://github.com/getsentry/sentry-javascript/issues/19241
+
+test('should create a correctly named pageload transaction for a static route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => {
+ return transactionEvent.transaction === '/static-page' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/static-page`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
+ 'sentry.source': 'url',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.nextjs.app_router_instrumentation',
+ },
+ },
+ transaction: '/static-page',
+ transaction_info: { source: 'url' },
+ type: 'transaction',
+ });
+});
+
+test('should create a correctly named pageload transaction for a parameterized route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/parameterized/:param' && transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/parameterized/some-value`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.nextjs.app_router_instrumentation',
+ },
+ },
+ transaction: '/parameterized/:param',
+ transaction_info: { source: 'route' },
+ type: 'transaction',
+ });
+});
+
+test('should create a correctly named pageload transaction for a static nested route under parameterized', async ({
+ page,
+}) => {
+ const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => {
+ return (
+ transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload'
+ );
+ });
+
+ await page.goto(`/parameterized/static`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
+ 'sentry.source': 'url',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.nextjs.app_router_instrumentation',
+ },
+ },
+ transaction: '/parameterized/static',
+ transaction_info: { source: 'url' },
+ type: 'transaction',
+ });
+});
+
+test('should create a correctly named pageload transaction for the catch-all route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => {
+ return transactionEvent.transaction === '/:slug*' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/some/unmatched/path`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.nextjs.app_router_instrumentation',
+ },
+ },
+ transaction: '/:slug*',
+ transaction_info: { source: 'route' },
+ type: 'transaction',
+ });
+});
+
+test('should create a correctly named pageload transaction for the home page', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => {
+ return transactionEvent.transaction === '/' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
+ 'sentry.source': 'url',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.nextjs.app_router_instrumentation',
+ },
+ },
+ transaction: '/',
+ transaction_info: { source: 'url' },
+ type: 'transaction',
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json
new file mode 100644
index 000000000000..cc9ed39b5aa2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
index 4006496d4a23..3ecc2aec7a6b 100644
--- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
@@ -9,6 +9,14 @@ import {
import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react';
import { maybeParameterizeRoute } from './parameterization';
+/**
+ * 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;
+}
+
export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction';
/**
@@ -35,10 +43,11 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und
/** Instruments the Next.js app router for pageloads. */
export function appRouterInstrumentPageLoad(client: Client): void {
- const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname);
+ const pathname = stripTrailingSlash(WINDOW.location.pathname);
+ const parameterizedPathname = maybeParameterizeRoute(pathname);
const origin = browserPerformanceTimeOrigin();
startBrowserTracingPageLoadSpan(client, {
- name: parameterizedPathname ?? WINDOW.location.pathname,
+ name: parameterizedPathname ?? pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTime: origin ? origin / 1000 : undefined,
attributes: {
@@ -93,7 +102,7 @@ export function appRouterInstrumentNavigation(client: Client): void {
routerTransitionHandler = (href, navigationType) => {
const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath;
const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href;
- const unparameterizedPathname = new URL(normalizedHref, WINDOW.location.href).pathname;
+ const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname);
const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname);
const pathname = parameterizedPathname ?? unparameterizedPathname;
@@ -123,16 +132,17 @@ export function appRouterInstrumentNavigation(client: Client): void {
};
WINDOW.addEventListener('popstate', () => {
- const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname);
+ const pathname = stripTrailingSlash(WINDOW.location.pathname);
+ const parameterizedPathname = maybeParameterizeRoute(pathname);
if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) {
- currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? WINDOW.location.pathname);
+ currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? pathname);
currentRouterPatchingNavigationSpanRef.current.setAttribute(
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
parameterizedPathname ? 'route' : 'url',
);
} else {
currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, {
- name: parameterizedPathname ?? WINDOW.location.pathname,
+ name: parameterizedPathname ?? pathname,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url',
@@ -217,10 +227,10 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe
const normalizedHref =
basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href;
if (routerFunctionName === 'push') {
- transactionName = transactionNameifyRouterArgument(normalizedHref);
+ transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
transactionAttributes['navigation.type'] = 'router.push';
} else if (routerFunctionName === 'replace') {
- transactionName = transactionNameifyRouterArgument(normalizedHref);
+ transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref));
transactionAttributes['navigation.type'] = 'router.replace';
} else if (routerFunctionName === 'back') {
transactionAttributes['navigation.type'] = 'router.back';
diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts
index 1bf6c22d5fe0..6bcffa43d420 100644
--- a/packages/nextjs/src/client/routing/parameterization.ts
+++ b/packages/nextjs/src/client/routing/parameterization.ts
@@ -178,9 +178,16 @@ export const maybeParameterizeRoute = (route: string): string | undefined => {
return undefined;
}
+ // Normalize trailing slashes to handle `trailingSlash: true` in Next.js config.
+ // When trailingSlash is enabled, all URLs get a trailing slash appended (e.g. '/about' becomes '/about/'),
+ // but route manifests store paths without trailing slashes. Without normalization, static routes fail
+ // exact-match checks and dynamic route regexes don't match, causing all routes to fall through to
+ // catch-all patterns. See: https://github.com/getsentry/sentry-javascript/issues/19241
+ const normalizedRoute = route.length > 1 && route.endsWith('/') ? route.slice(0, -1) : route;
+
// Check route result cache after manifest validation
- if (routeResultCache.has(route)) {
- return routeResultCache.get(route);
+ if (routeResultCache.has(normalizedRoute)) {
+ return routeResultCache.get(normalizedRoute);
}
const { staticRoutes, dynamicRoutes } = manifest;
@@ -188,12 +195,12 @@ export const maybeParameterizeRoute = (route: string): string | undefined => {
return undefined;
}
- const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes);
+ const matches = findMatchingRoutes(normalizedRoute, staticRoutes, dynamicRoutes);
// We can always do the `sort()` call, it will short-circuit when it has one array item
const result = matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0];
- routeResultCache.set(route, result);
+ routeResultCache.set(normalizedRoute, result);
return result;
};
diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts
index e593596aa8c1..dd7abff0bcd0 100644
--- a/packages/nextjs/test/client/parameterization.test.ts
+++ b/packages/nextjs/test/client/parameterization.test.ts
@@ -933,4 +933,133 @@ describe('maybeParameterizeRoute', () => {
expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about');
});
});
+
+ describe('trailing slash normalization (trailingSlash: true)', () => {
+ it('should match static routes when path has a trailing slash', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/settings/profile' }],
+ dynamicRoutes: [],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRoute('/about/')).toBeUndefined();
+ expect(maybeParameterizeRoute('/settings/profile/')).toBeUndefined();
+ // Root path should still work
+ expect(maybeParameterizeRoute('/')).toBeUndefined();
+ });
+
+ it('should match dynamic routes when path has a trailing slash', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/users/:id/posts/:postId',
+ regex: '^/users/([^/]+)/posts/([^/]+)$',
+ paramNames: ['id', 'postId'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ expect(maybeParameterizeRoute('/users/123/')).toBe('/users/:id');
+ expect(maybeParameterizeRoute('/users/123/posts/456/')).toBe('/users/:id/posts/:postId');
+ });
+
+ it('should not incorrectly match catch-all routes when a more specific route exists and path has trailing slash', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }],
+ dynamicRoutes: [
+ {
+ path: '/blog/:slug',
+ regex: '^/blog/([^/]+)$',
+ paramNames: ['slug'],
+ },
+ {
+ path: '/parameterized/:param',
+ regex: '^/parameterized/([^/]+)$',
+ paramNames: ['param'],
+ },
+ {
+ path: '/:slug*',
+ regex: '^/(.+)$',
+ paramNames: ['slug'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Static routes with trailing slash should NOT fall through to catch-all
+ expect(maybeParameterizeRoute('/about/')).toBeUndefined();
+ expect(maybeParameterizeRoute('/contact/')).toBeUndefined();
+
+ // Dynamic routes with trailing slash should match correctly, not catch-all
+ expect(maybeParameterizeRoute('/blog/my-post/')).toBe('/blog/:slug');
+ expect(maybeParameterizeRoute('/parameterized/some-value/')).toBe('/parameterized/:param');
+
+ // Actual catch-all routes should still work
+ expect(maybeParameterizeRoute('/unknown/path')).toBe('/:slug*');
+ expect(maybeParameterizeRoute('/unknown/path/')).toBe('/:slug*');
+ });
+
+ it('should handle trailing slash with optional catch-all routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/static-page' }],
+ dynamicRoutes: [
+ {
+ path: '/parameterized/:param',
+ regex: '^/parameterized/([^/]+)$',
+ paramNames: ['param'],
+ },
+ {
+ path: '/:slug*?',
+ regex: '^/(.*)$',
+ paramNames: ['slug'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Static route with trailing slash should not match the optional catch-all
+ expect(maybeParameterizeRoute('/static-page/')).toBeUndefined();
+
+ // Dynamic route with trailing slash should not match the optional catch-all
+ expect(maybeParameterizeRoute('/parameterized/value/')).toBe('/parameterized/:param');
+
+ // Root with trailing slash is just '/' - should match static
+ expect(maybeParameterizeRoute('/')).toBeUndefined();
+ });
+
+ it('should produce the same result for paths with and without trailing slashes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }, { path: '/about' }],
+ dynamicRoutes: [
+ {
+ path: '/users/:id',
+ regex: '^/users/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/:slug*',
+ regex: '^/(.+)$',
+ paramNames: ['slug'],
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Static routes
+ expect(maybeParameterizeRoute('/about')).toBe(maybeParameterizeRoute('/about/'));
+
+ // Dynamic routes
+ expect(maybeParameterizeRoute('/users/123')).toBe(maybeParameterizeRoute('/users/123/'));
+
+ // Root
+ expect(maybeParameterizeRoute('/')).toBe(maybeParameterizeRoute('/'));
+ });
+ });
});