Skip to content

Commit af83b87

Browse files
authored
fix(nextjs): Inconsistent transaction naming for i18n routing (#17927)
### Problem When using Next.js 15 App Router with `next-intl` and `localePrefix: "as-needed"`, Web Vitals and transaction names were inconsistent across locales: - `/foo` (default locale, no prefix) → Transaction: `/:locale` ❌ - `/ar/foo` (non-default locale, with prefix) → Transaction: `/:locale/foo` ✅ This caused all default locale pages to collapse into a single `/:locale` transaction, making Web Vitals data unusable for apps with i18n routing. After investigation it seems like the route parameterization logic couldn't match `/foo` (1 segment) to the `/:locale/foo` pattern (expects 2 segments) because the locale prefix is omitted in default locale URLs. ### Solution Implemented enhanced route matching with automatic i18n prefix detection: 1. **Route Manifest Metadata** - Added `hasOptionalPrefix` flag to route info to identify routes with common i18n parameter names (`locale`, `lang`, `language`) 2. **Smart Fallback Matching** - When a route doesn't match directly, the matcher now tries prepending a placeholder segment for routes flagged with `hasOptionalPrefix` - Example: `/foo` → tries matching as `/PLACEHOLDER/foo` → matches `/:locale/foo` ✓ 3. **Updated Specificity Scoring** - changed route specificity calculation to prefer longer routes when dynamic segment counts are equal - Example: `/:locale/foo` (2 segments) now beats `/:locale` (1 segment) ### Result **After fix:** ``` URL: /foo → Transaction: /:locale/foo ✅ URL: /ar/foo → Transaction: /:locale/foo ✅ URL: /products → Transaction: /:locale/products ✅ URL: /ar/products → Transaction: /:locale/products ✅ ``` All routes now consistently use the same parameterized transaction name regardless of locale, making Web Vitals properly grouped and usable. ### Backwards Compatibility - No breaking changes - only applies when direct matching would fail - Only affects routes with first param named `locale`/`lang`/`language` - Non-i18n apps completely unaffected - Direct matches always take precedence over optional prefix matching Fixes #17775 --- Maybe we should make certain aspects of this configurable, like the `['locale', 'lang', 'language']` collection
1 parent 66dc9a2 commit af83b87

File tree

29 files changed

+775
-3
lines changed

29 files changed

+775
-3
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.*
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/versions
12+
13+
# testing
14+
/coverage
15+
16+
# next.js
17+
/.next/
18+
/out/
19+
20+
# production
21+
/build
22+
23+
# misc
24+
.DS_Store
25+
*.pem
26+
27+
# debug
28+
npm-debug.log*
29+
yarn-debug.log*
30+
yarn-error.log*
31+
32+
# env files (can opt-in for commiting if needed)
33+
.env*
34+
35+
# vercel
36+
.vercel
37+
38+
# typescript
39+
*.tsbuildinfo
40+
next-env.d.ts
41+
42+
# Sentry
43+
.sentryclirc
44+
45+
pnpm-lock.yaml
46+
.tmp_dev_server_logs
47+
.tmp_build_stdout
48+
.tmp_build_stderr
49+
event-dumps
50+
test-results
51+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://localhost:4873
2+
@sentry-internal:registry=http://localhost:4873
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) {
2+
const { locale } = await params;
3+
return (
4+
<div>
5+
<h1>I18n Test Page</h1>
6+
<p>Current locale: {locale}</p>
7+
</div>
8+
);
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default async function LocaleRootPage({ params }: { params: Promise<{ locale: string }> }) {
2+
const { locale } = await params;
3+
return (
4+
<div>
5+
<h1>Locale Root</h1>
6+
<p>Current locale: {locale}</p>
7+
</div>
8+
);
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const metadata = {
2+
title: 'Next.js 15 i18n Test',
3+
};
4+
5+
export default function RootLayout({ children }: { children: React.ReactNode }) {
6+
return (
7+
<html lang="en">
8+
<body>{children}</body>
9+
</html>
10+
);
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getRequestConfig } from 'next-intl/server';
2+
import { hasLocale } from 'next-intl';
3+
import { routing } from './routing';
4+
5+
export default getRequestConfig(async ({ requestLocale }) => {
6+
// Typically corresponds to the `[locale]` segment
7+
const requested = await requestLocale;
8+
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
9+
10+
return {
11+
locale,
12+
messages: {},
13+
};
14+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineRouting } from 'next-intl/routing';
2+
import { createNavigation } from 'next-intl/navigation';
3+
4+
export const routing = defineRouting({
5+
locales: ['en', 'ar', 'fr'],
6+
defaultLocale: 'en',
7+
localePrefix: 'as-needed',
8+
});
9+
10+
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
Sentry.init({
4+
environment: 'qa',
5+
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
6+
tunnel: `http://localhost:3031/`,
7+
tracesSampleRate: 1.0,
8+
sendDefaultPii: true,
9+
});
10+
11+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
export async function register() {
4+
if (process.env.NEXT_RUNTIME === 'nodejs') {
5+
await import('./sentry.server.config');
6+
}
7+
8+
if (process.env.NEXT_RUNTIME === 'edge') {
9+
await import('./sentry.edge.config');
10+
}
11+
}
12+
13+
export const onRequestError = Sentry.captureRequestError;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import createMiddleware from 'next-intl/middleware';
2+
import { routing } from './i18n/routing';
3+
4+
export default createMiddleware(routing);
5+
6+
export const config = {
7+
matcher: ['/((?!api|_next|.*\\..*).*)'],
8+
};

0 commit comments

Comments
 (0)