diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore
new file mode 100644
index 000000000000..2d0dd371dc86
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore
@@ -0,0 +1,51 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# env files (can opt-in for commiting if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# Sentry
+.sentryclirc
+
+pnpm-lock.yaml
+.tmp_dev_server_logs
+.tmp_build_stdout
+.tmp_build_stderr
+event-dumps
+test-results
+
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc
new file mode 100644
index 000000000000..c6b3ef9b3eaa
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://localhost:4873
+@sentry-internal:registry=http://localhost:4873
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx
new file mode 100644
index 000000000000..7e2e8d45db06
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx
@@ -0,0 +1,9 @@
+export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params;
+ return (
+
+
I18n Test Page
+
Current locale: {locale}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx
new file mode 100644
index 000000000000..23e7b3213a3f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx
@@ -0,0 +1,9 @@
+export default async function LocaleRootPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params;
+ return (
+
+
Locale Root
+
Current locale: {locale}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx
new file mode 100644
index 000000000000..60b3740fd7a2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx
@@ -0,0 +1,11 @@
+export const metadata = {
+ title: 'Next.js 15 i18n Test',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts
new file mode 100644
index 000000000000..5ed375a9107a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts
@@ -0,0 +1,14 @@
+import { getRequestConfig } from 'next-intl/server';
+import { hasLocale } from 'next-intl';
+import { routing } from './routing';
+
+export default getRequestConfig(async ({ requestLocale }) => {
+ // Typically corresponds to the `[locale]` segment
+ const requested = await requestLocale;
+ const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
+
+ return {
+ locale,
+ messages: {},
+ };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts
new file mode 100644
index 000000000000..efa95881eabc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts
@@ -0,0 +1,10 @@
+import { defineRouting } from 'next-intl/routing';
+import { createNavigation } from 'next-intl/navigation';
+
+export const routing = defineRouting({
+ locales: ['en', 'ar', 'fr'],
+ defaultLocale: 'en',
+ localePrefix: 'as-needed',
+});
+
+export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts
new file mode 100644
index 000000000000..c232101a75e3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts
@@ -0,0 +1,11 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa',
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`,
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
+
+export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts
new file mode 100644
index 000000000000..964f937c439a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-15-intl/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts
new file mode 100644
index 000000000000..14e2b3ce738a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts
@@ -0,0 +1,8 @@
+import createMiddleware from 'next-intl/middleware';
+import { routing } from './i18n/routing';
+
+export default createMiddleware(routing);
+
+export const config = {
+ matcher: ['/((?!api|_next|.*\\..*).*)'],
+};
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js
new file mode 100644
index 000000000000..edd191e14b38
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js
@@ -0,0 +1,11 @@
+const { withSentryConfig } = require('@sentry/nextjs');
+const createNextIntlPlugin = require('next-intl/plugin');
+
+const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+module.exports = withSentryConfig(withNextIntl(nextConfig), {
+ silent: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json
new file mode 100644
index 000000000000..359b939eaf50
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "nextjs-15-intl",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "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",
+ "test:prod": "TEST_ENV=production playwright test",
+ "test:dev": "TEST_ENV=development playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test:prod && pnpm test:dev"
+ },
+ "dependencies": {
+ "@sentry/nextjs": "latest || *",
+ "@types/node": "^18.19.1",
+ "@types/react": "18.0.26",
+ "@types/react-dom": "18.0.9",
+ "next": "15.5.4",
+ "next-intl": "^4.3.12",
+ "react": "latest",
+ "react-dom": "latest",
+ "typescript": "~5.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.53.2",
+ "@sentry-internal/test-utils": "link:../../../test-utils"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs
new file mode 100644
index 000000000000..38548e975851
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-15-intl/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts
new file mode 100644
index 000000000000..e9521895498e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa',
+ dsn: process.env.SENTRY_DSN,
+ tunnel: `http://localhost:3031/`,
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts
new file mode 100644
index 000000000000..760b8b581a29
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/nextjs';
+
+Sentry.init({
+ environment: 'qa',
+ dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`,
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+ transportOptions: {
+ bufferSize: 1000,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs
new file mode 100644
index 000000000000..8f6b9b5886d5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-15-intl',
+ envelopeDumpPath: path.join(
+ process.cwd(),
+ `event-dumps/nextjs-15-intl-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`,
+ ),
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts
new file mode 100644
index 000000000000..0943df8c7216
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts
@@ -0,0 +1,90 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('should create consistent parameterized transaction for default locale without prefix', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/i18n-test`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ transaction: '/:locale/i18n-test',
+ transaction_info: { source: 'route' },
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.source': 'route',
+ },
+ },
+ },
+ });
+});
+
+test('should create consistent parameterized transaction for non-default locale with prefix', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/ar/i18n-test`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ transaction: '/:locale/i18n-test',
+ transaction_info: { source: 'route' },
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.source': 'route',
+ },
+ },
+ },
+ });
+});
+
+test('should parameterize locale root page correctly for default locale without prefix', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ transaction: '/:locale',
+ transaction_info: { source: 'route' },
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.source': 'route',
+ },
+ },
+ },
+ });
+});
+
+test('should parameterize locale root page correctly for non-default locale with prefix', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/fr`);
+
+ const transaction = await transactionPromise;
+
+ expect(transaction).toMatchObject({
+ transaction: '/:locale',
+ transaction_info: { source: 'route' },
+ contexts: {
+ trace: {
+ data: {
+ 'sentry.source': 'route',
+ },
+ },
+ },
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json
new file mode 100644
index 000000000000..64c21044c49f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "target": "ES2017"
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx
new file mode 100644
index 000000000000..10c32a944514
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx
@@ -0,0 +1,10 @@
+export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params;
+ return (
+
+
I18n Test Page
+
Current locale: {locale || 'default'}
+
This page tests i18n route parameterization
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts
new file mode 100644
index 000000000000..fda0645fa1a3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('should create consistent parameterized transaction for i18n routes - locale: en', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/en/i18n-test`);
+
+ 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: '/:locale/i18n-test',
+ transaction_info: { source: 'route' },
+ type: 'transaction',
+ });
+});
+
+test('should create consistent parameterized transaction for i18n routes - locale: ar', async ({ page }) => {
+ const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
+ return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto(`/ar/i18n-test`);
+
+ 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: '/:locale/i18n-test',
+ transaction_info: { source: 'route' },
+ type: 'transaction',
+ });
+});
diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts
index c20d71614234..d13097435f41 100644
--- a/packages/nextjs/src/client/routing/parameterization.ts
+++ b/packages/nextjs/src/client/routing/parameterization.ts
@@ -37,6 +37,16 @@ function getRouteSpecificity(routePath: string): number {
// Static segments add 0 to score as they are most specific
}
+ if (segments.length > 0) {
+ // Add a small penalty based on inverse of segment count
+ // This ensures that routes with more segments are preferred
+ // e.g., '/:locale/foo' is more specific than '/:locale'
+ // We use a small value (1 / segments.length) so it doesn't override the main scoring
+ // but breaks ties between routes with the same number of dynamic segments
+ const segmentCountPenalty = 1 / segments.length;
+ score += segmentCountPenalty;
+ }
+
return score;
}
@@ -134,6 +144,24 @@ function findMatchingRoutes(
}
}
+ // Try matching with optional prefix segments (for i18n routing patterns)
+ // This handles cases like '/foo' matching '/:locale/foo' when using next-intl with localePrefix: "as-needed"
+ // We do this regardless of whether we found direct matches, as we want the most specific match
+ if (!route.startsWith('/:')) {
+ for (const dynamicRoute of dynamicRoutes) {
+ if (dynamicRoute.hasOptionalPrefix && dynamicRoute.regex) {
+ // Prepend a placeholder segment to simulate the optional prefix
+ // e.g., '/foo' becomes '/PLACEHOLDER/foo' to match '/:locale/foo'
+ // Special case: '/' becomes '/PLACEHOLDER' (not '/PLACEHOLDER/') to match '/:locale' pattern
+ const routeWithPrefix = route === '/' ? '/SENTRY_OPTIONAL_PREFIX' : `/SENTRY_OPTIONAL_PREFIX${route}`;
+ const regex = getCompiledRegex(dynamicRoute.regex);
+ if (regex?.test(routeWithPrefix)) {
+ matches.push(dynamicRoute.path);
+ }
+ }
+ }
+ }
+
return matches;
}
diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts
index 32e7db61b57b..5e2a99f66285 100644
--- a/packages/nextjs/src/config/manifest/createRouteManifest.ts
+++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts
@@ -47,7 +47,11 @@ function getDynamicRouteSegment(name: string): string {
return `:${name.slice(1, -1)}`;
}
-function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } {
+function buildRegexForDynamicRoute(routePath: string): {
+ regex: string;
+ paramNames: string[];
+ hasOptionalPrefix: boolean;
+} {
const segments = routePath.split('/').filter(Boolean);
const regexSegments: string[] = [];
const paramNames: string[] = [];
@@ -95,7 +99,20 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam
pattern = `^/${regexSegments.join('/')}$`;
}
- return { regex: pattern, paramNames };
+ return { regex: pattern, paramNames, hasOptionalPrefix: hasOptionalPrefix(paramNames) };
+}
+
+/**
+ * Detect if the first parameter is a common i18n prefix segment
+ * Common patterns: locale, lang, language
+ */
+function hasOptionalPrefix(paramNames: string[]): boolean {
+ const firstParam = paramNames[0];
+ if (firstParam === undefined) {
+ return false;
+ }
+
+ return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language';
}
function scanAppDirectory(
@@ -116,11 +133,12 @@ function scanAppDirectory(
const isDynamic = routePath.includes(':');
if (isDynamic) {
- const { regex, paramNames } = buildRegexForDynamicRoute(routePath);
+ const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath);
dynamicRoutes.push({
path: routePath,
regex,
paramNames,
+ hasOptionalPrefix,
});
} else {
staticRoutes.push({
diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts
index e3a26adfce2f..0a0946be70f7 100644
--- a/packages/nextjs/src/config/manifest/types.ts
+++ b/packages/nextjs/src/config/manifest/types.ts
@@ -14,6 +14,11 @@ export type RouteInfo = {
* (Optional) The names of dynamic parameters in the route
*/
paramNames?: string[];
+ /**
+ * (Optional) Indicates if the first segment is an optional prefix (e.g., for i18n routing)
+ * When true, routes like '/foo' should match '/:locale/foo' patterns
+ */
+ hasOptionalPrefix?: boolean;
};
/**
diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts
index e9f484e71827..e593596aa8c1 100644
--- a/packages/nextjs/test/client/parameterization.test.ts
+++ b/packages/nextjs/test/client/parameterization.test.ts
@@ -644,4 +644,293 @@ describe('maybeParameterizeRoute', () => {
expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*');
});
});
+
+ describe('i18n routing with optional prefix', () => {
+ it('should match routes with optional locale prefix for default locale paths', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }],
+ dynamicRoutes: [
+ {
+ path: '/:locale',
+ regex: '^/([^/]+)$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/foo',
+ regex: '^/([^/]+)/foo$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/bar',
+ regex: '^/([^/]+)/bar$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/products',
+ regex: '^/([^/]+)/products$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Default locale paths (without prefix) should match parameterized routes
+ expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo');
+ expect(maybeParameterizeRoute('/bar')).toBe('/:locale/bar');
+ expect(maybeParameterizeRoute('/products')).toBe('/:locale/products');
+
+ // Non-default locale paths (with prefix) should also match
+ expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo');
+ expect(maybeParameterizeRoute('/ar/bar')).toBe('/:locale/bar');
+ expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products');
+ expect(maybeParameterizeRoute('/en/foo')).toBe('/:locale/foo');
+ expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products');
+ });
+
+ it('should handle nested routes with optional locale prefix', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:locale/foo/:id',
+ regex: '^/([^/]+)/foo/([^/]+)$',
+ paramNames: ['locale', 'id'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/products/:productId',
+ regex: '^/([^/]+)/products/([^/]+)$',
+ paramNames: ['locale', 'productId'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Default locale (no prefix)
+ expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id');
+ expect(maybeParameterizeRoute('/products/abc')).toBe('/:locale/products/:productId');
+
+ // Non-default locale (with prefix)
+ expect(maybeParameterizeRoute('/ar/foo/123')).toBe('/:locale/foo/:id');
+ expect(maybeParameterizeRoute('/ar/products/abc')).toBe('/:locale/products/:productId');
+ expect(maybeParameterizeRoute('/en/foo/456')).toBe('/:locale/foo/:id');
+ });
+
+ it('should prioritize direct matches over optional prefix matches', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/foo/:id',
+ regex: '^/foo/([^/]+)$',
+ paramNames: ['id'],
+ },
+ {
+ path: '/:locale/foo',
+ regex: '^/([^/]+)/foo$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Direct match should win
+ expect(maybeParameterizeRoute('/foo/123')).toBe('/foo/:id');
+
+ // Optional prefix match when direct match isn't available
+ expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo');
+ expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo');
+ });
+
+ it('should handle lang and language parameters as optional prefixes', () => {
+ const manifestWithLang: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:lang/page',
+ regex: '^/([^/]+)/page$',
+ paramNames: ['lang'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLang);
+ expect(maybeParameterizeRoute('/page')).toBe('/:lang/page');
+ expect(maybeParameterizeRoute('/en/page')).toBe('/:lang/page');
+
+ const manifestWithLanguage: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:language/page',
+ regex: '^/([^/]+)/page$',
+ paramNames: ['language'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLanguage);
+ expect(maybeParameterizeRoute('/page')).toBe('/:language/page');
+ expect(maybeParameterizeRoute('/en/page')).toBe('/:language/page');
+ });
+
+ it('should not apply optional prefix logic to non-i18n dynamic segments', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:userId/profile',
+ regex: '^/([^/]+)/profile$',
+ paramNames: ['userId'],
+ hasOptionalPrefix: false,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Should not match without the userId segment
+ expect(maybeParameterizeRoute('/profile')).toBeUndefined();
+
+ // Should match with the userId segment
+ expect(maybeParameterizeRoute('/123/profile')).toBe('/:userId/profile');
+ });
+
+ it('should handle real-world next-intl scenario', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [{ path: '/' }],
+ dynamicRoutes: [
+ {
+ path: '/:locale',
+ regex: '^/([^/]+)$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/hola',
+ regex: '^/([^/]+)/hola$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/products',
+ regex: '^/([^/]+)/products$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Root should not be parameterized (it's a static route)
+ expect(maybeParameterizeRoute('/')).toBeUndefined();
+
+ // Default locale (English, no prefix) - this was the bug
+ expect(maybeParameterizeRoute('/hola')).toBe('/:locale/hola');
+ expect(maybeParameterizeRoute('/products')).toBe('/:locale/products');
+
+ // Non-default locale (Arabic, with prefix)
+ expect(maybeParameterizeRoute('/ar')).toBe('/:locale');
+ expect(maybeParameterizeRoute('/ar/hola')).toBe('/:locale/hola');
+ expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products');
+
+ // Other locales
+ expect(maybeParameterizeRoute('/en/hola')).toBe('/:locale/hola');
+ expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products');
+ });
+
+ it('should prefer more specific routes over optional prefix matches', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:locale',
+ regex: '^/([^/]+)$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/foo/:id',
+ regex: '^/([^/]+)/foo/([^/]+)$',
+ paramNames: ['locale', 'id'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/foo',
+ regex: '^/([^/]+)/foo$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // More specific route should win (specificity score)
+ expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id');
+ expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo');
+ expect(maybeParameterizeRoute('/about')).toBe('/:locale');
+ });
+
+ it('should handle deeply nested i18n routes', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:locale/users/:userId/posts/:postId/comments/:commentId',
+ regex: '^/([^/]+)/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$',
+ paramNames: ['locale', 'userId', 'postId', 'commentId'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Without locale prefix (default locale)
+ expect(maybeParameterizeRoute('/users/123/posts/456/comments/789')).toBe(
+ '/:locale/users/:userId/posts/:postId/comments/:commentId',
+ );
+
+ // With locale prefix
+ expect(maybeParameterizeRoute('/ar/users/123/posts/456/comments/789')).toBe(
+ '/:locale/users/:userId/posts/:postId/comments/:commentId',
+ );
+ });
+
+ it('should handle root path with optional locale prefix', () => {
+ const manifest: RouteManifest = {
+ staticRoutes: [],
+ dynamicRoutes: [
+ {
+ path: '/:locale',
+ regex: '^/([^/]+)$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ {
+ path: '/:locale/about',
+ regex: '^/([^/]+)/about$',
+ paramNames: ['locale'],
+ hasOptionalPrefix: true,
+ },
+ ],
+ };
+ globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest);
+
+ // Root path without locale prefix (default locale)
+ expect(maybeParameterizeRoute('/')).toBe('/:locale');
+
+ // Root path with locale prefix
+ expect(maybeParameterizeRoute('/en')).toBe('/:locale');
+ expect(maybeParameterizeRoute('/ar')).toBe('/:locale');
+
+ // Nested routes still work
+ expect(maybeParameterizeRoute('/about')).toBe('/:locale/about');
+ expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about');
+ });
+ });
});
diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts
index a1014b05c32c..097e3f603693 100644
--- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts
+++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts
@@ -16,6 +16,7 @@ describe('basePath', () => {
path: '/my-app/users/:id',
regex: '^/my-app/users/([^/]+)$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
],
});
diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts
index b7108b6f6f23..8d78f24a0986 100644
--- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts
+++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts
@@ -13,6 +13,7 @@ describe('catchall', () => {
path: '/:path*?',
regex: '^/(.*)$',
paramNames: ['path'],
+ hasOptionalPrefix: false,
},
],
});
diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts
index b1c417970ba4..d259a1a38223 100644
--- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts
+++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts
@@ -13,6 +13,7 @@ describe('catchall', () => {
path: '/catchall/:path*?',
regex: '^/catchall(?:/(.*))?$',
paramNames: ['path'],
+ hasOptionalPrefix: false,
},
],
});
diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts
index fdcae299d7cf..2ea4b4aca5d8 100644
--- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts
+++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts
@@ -13,21 +13,25 @@ describe('dynamic', () => {
path: '/dynamic/:id',
regex: '^/dynamic/([^/]+)$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
{
path: '/users/:id',
regex: '^/users/([^/]+)$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
{
path: '/users/:id/posts/:postId',
regex: '^/users/([^/]+)/posts/([^/]+)$',
paramNames: ['id', 'postId'],
+ hasOptionalPrefix: false,
},
{
path: '/users/:id/settings',
regex: '^/users/([^/]+)/settings$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
],
});
diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts
index 36ac9077df7e..8e1fe463190e 100644
--- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts
+++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts
@@ -23,6 +23,7 @@ describe('route-groups', () => {
path: '/dashboard/:id',
regex: '^/dashboard/([^/]+)$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
],
});
@@ -55,6 +56,7 @@ describe('route-groups', () => {
path: '/(dashboard)/dashboard/:id',
regex: '^/\\(dashboard\\)/dashboard/([^/]+)$',
paramNames: ['id'],
+ hasOptionalPrefix: false,
},
],
});