Skip to content

Next.js 15.5.2 next-intl: Web Vitals duplicated across /:locale and normalized routes despite transaction normalization attempts #17775

@Yazan-Ali9

Description

@Yazan-Ali9

Is there an existing issue for this?

How do you use Sentry?

Sentry Saas (sentry.io)

Which SDK are you using?

@sentry/nextjs

SDK Version

10.14.0

Framework Version

Next 15.5.2

Link to Sentry event

No response

Reproduction Example/SDK Setup

GitHub Issue: Sentry + Next.js 15 + next-intl Web Vitals Issue

Issue Title: Critical: Next.js 15 + next-intl App Router creates unusable Sentry Web Vitals - all English routes collapse to /:locale, navigation not tracked

Repository: https://github.com/getsentry/sentry-javascript/issues


Description

I'm experiencing a critical issue with Sentry Web Vitals in a Next.js 15 App Router application using next-intl with localePrefix: "as-needed". The transaction naming is completely broken, making performance monitoring unusable.

Environment

  • Sentry SDK Version: @sentry/[email protected]
  • Next.js Version: 15.5.2
  • next-intl Version: ^4.3.7
  • Configuration: App Router with app/[locale]/ structure

Critical Issues

1. All English routes collapse to /:locale

When browsing in English (default locale, no URL prefix):

  • Visiting / creates transaction: /:locale
  • Visiting /foo creates transaction: /:locale
  • Visiting /bar creates transaction: /:locale
  • All different pages show as the same route in Sentry

2. Arabic routes get parameterized patterns

When browsing in Arabic (with /ar prefix):

  • Visiting /ar/foo creates transaction: /:locale/foo
  • Should be normalized to /foo for proper grouping

3. Duplicate transactions for root page

  • Visiting / (English) sometimes creates BOTH:
    • Transaction: / (correct)
    • Transaction: /:locale (incorrect duplicate)

4. Next.js navigation completely broken

  • Locale switching (e.g., from / to /ar) doesn't create Web Vitals transactions
  • <Link> navigation between pages doesn't trigger Web Vitals
  • Only direct page loads/refreshes create performance data

Current Configuration

next.config.ts

import {withSentryConfig} from "@sentry/nextjs";
import { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const nextConfig: NextConfig = {
  output: "standalone",
  transpilePackages: ["@t3-oss/env-nextjs", "@t3-oss/env-core"],
  htmlLimitedBots: /.*/,
  productionBrowserSourceMaps: true,
};

const withNextIntl = createNextIntlPlugin({
  requestConfig: './i18n/request.ts',
  experimental: {
      createMessagesDeclaration: ["./messages/en.json", "./messages/ar.json"],
    },
  }
);

export default withSentryConfig(withNextIntl(nextConfig), {
  org: "fyler",
  project: "javascript-nextjs-5v",
  authToken: process.env.SENTRY_AUTH_TOKEN,
  silent: !process.env.CI,
  widenClientFileUpload: true,
  disableLogger: true,
  release: { setCommits: { auto: true } },
  sourcemaps: {
    disable: false,
    assets: [
      ".next/static/**/*.js",
      ".next/static/**/*.js.map",
      ".next/server/**/*.js",
      ".next/server/**/*.js.map",
      ".next/edge-runtime-webpack.js",
      ".next/edge-runtime-webpack.js.map",
      ".next/instrumentation.js",
      ".next/instrumentation.js.map",
      ".next/middleware.js",
      ".next/middleware.js.map"
    ],
    ignore: ["**/node_modules/**"],
    deleteSourcemapsAfterUpload: false,
  },
});

instrumentation-client.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  integrations: [Sentry.replayIntegration()],
  tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
  replaysSessionSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
  replaysOnErrorSampleRate: 1.0,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

sentry.server.config.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
});

sentry.edge.config.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.05 : 1.0,
});

instrumentation.ts

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;

What I've Tried

I'm aware of issue #4677 but most solutions there are deprecated in the latest SDK versions. I've attempted numerous approaches:

Deprecated/Non-working approaches from that discussion:

  • beforeNavigate - removed from SDK
  • Integrations.BrowserTracing - deprecated syntax
  • Manual startTransaction - deprecated API
  • Various route normalization hooks - don't prevent the core issue

Modern attempts that also failed:

  • beforeStartSpan in browserTracingIntegration
  • beforeSendTransaction filtering
  • Setting disableManifestInjection: true
  • Comprehensive transaction name normalization
  • Dropping problematic transactions entirely

Expected Behavior

Transaction names should be:

  • / for root page (regardless of locale)
  • /foo for foo page (regardless of locale)
  • /bar for bar page (regardless of locale)

With locale preserved as tags: i18n.locale: en/ar

Navigation should trigger Web Vitals:

  • <Link> clicks between pages
  • Locale switching
  • Programmatic navigation

Actual Behavior

English browsing:

/ → Transaction: /:locale (sometimes also /)
/foo → Transaction: /:locale  
/bar → Transaction: /:locale

Arabic browsing:

/ar → Transaction: /:locale (should be /)
/ar/foo → Transaction: /:locale/foo (should be /foo)
/ar/bar → Transaction: /:locale/bar (should be /bar)

Navigation: No Web Vitals data for any client-side navigation.

Impact

This makes Sentry completely unusable for performance monitoring because:

  1. Cannot distinguish between pages - all English routes appear as /:locale
  2. No navigation tracking - only page loads generate data
  3. Fragmented data - same logical pages have different transaction names per locale
  4. Cannot measure user journeys - no data for typical SPA navigation

Root Cause

The issue seems to be that Next.js 15 App Router with [locale] dynamic segments confuses Sentry's automatic instrumentation, which:

  1. Creates transactions based on file system routes (/:locale) instead of actual URLs
  2. Doesn't properly handle next-intl's localePrefix: "as-needed" routing
  3. Fails to track client-side navigation in i18n contexts

Questions

  1. Is there a working solution for Next.js 15 + App Router + next-intl?
  2. Should we avoid localePrefix: "as-needed" entirely when using Sentry?
  3. Are there plans to fix i18n support in the Next.js SDK?
  4. Is there a way to completely override Sentry's automatic route detection?

This appears to be a fundamental compatibility issue between Sentry's Next.js integration and modern i18n patterns. Any guidance would be greatly appreciated.

Additional Context

  • This issue affects production applications using common i18n patterns
  • The problem makes Web Vitals monitoring completely unusable
  • Similar issues exist but most solutions are deprecated in current SDK versions
  • This seems like a critical compatibility gap that should be addressed

Labels to add when creating the issue:

  • bug
  • nextjs
  • performance
  • i18n
  • web-vitals
  • app-router

Steps to Reproduce

Steps to Reproduce

Prerequisites

  • Node.js 18+
  • Sentry account with a Next.js project created
  • Basic understanding of Next.js App Router

Step 1: Create Next.js 15 App with App Router

npx create-next-app@latest sentry-i18n-bug --typescript --tailwind --eslint --app --no-src-dir
cd sentry-i18n-bug

Step 2: Install Required Dependencies

npm install @sentry/[email protected] next-intl@^4.3.7 rtl-detect@^1.1.2

Step 3: Set Up File Structure

Create the following file structure:

app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── hola/
│   │   └── page.tsx
│   └── products/
│       └── page.tsx
├── globals.css
├── favicon.ico
├── global-error.tsx
└── not-found.tsx

i18n/
├── routing.ts
└── request.ts

messages/
├── en.json
└── ar.json

middleware.ts
instrumentation.ts
instrumentation-client.ts
sentry.server.config.ts
sentry.edge.config.ts
next.config.ts
.env.local

Step 4: Create Configuration Files

4.1 Create i18n/routing.ts

import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'ar'],
  defaultLocale: 'en',
  localePrefix: 'as-needed', // This is the key setting that causes the issue
});

4.2 Create i18n/request.ts

import { routing } from './routing';
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

4.3 Create messages/en.json

{
  "HomePage": {
    "title": "Welcome to our website",
    "description": "This is the English version"
  },
  "HolaPage": {
    "title": "Hello Page",
    "description": "This is the hello page in English"
  },
  "ProductsPage": {
    "title": "Products",
    "description": "Our products in English"
  }
}

4.4 Create messages/ar.json

{
  "HomePage": {
    "title": "مرحباً بكم في موقعنا",
    "description": "هذه هي النسخة العربية"
  },
  "HolaPage": {
    "title": "صفحة مرحبا",
    "description": "هذه صفحة الترحيب بالعربية"
  },
  "ProductsPage": {
    "title": "المنتجات",
    "description": "منتجاتنا بالعربية"
  }
}

4.5 Create middleware.ts

import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};

Step 5: Create App Router Pages

5.1 Create app/[locale]/layout.tsx

import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { routing } from '@/i18n/routing';
import '../globals.css';

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          <nav style={{ padding: '1rem', borderBottom: '1px solid #ccc' }}>
            <a href={`/${locale === 'en' ? '' : locale}`}>Home</a> |{' '}
            <a href={`/${locale === 'en' ? '' : locale}${locale === 'en' ? '' : '/'}hola`}>Hola</a> |{' '}
            <a href={`/${locale === 'en' ? '' : locale}${locale === 'en' ? '' : '/'}products`}>Products</a>
            <div style={{ marginTop: '0.5rem' }}>
              Language: 
              <a href="/" style={{ marginLeft: '0.5rem' }}>EN</a> | 
              <a href="/ar" style={{ marginLeft: '0.5rem' }}>AR</a>
            </div>
          </nav>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

5.2 Create app/[locale]/page.tsx

import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations('HomePage');

  return (
    <div style={{ padding: '2rem' }}>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
      <p>Current URL: <code>{typeof window !== 'undefined' ? window.location.pathname : 'Server'}</code></p>
    </div>
  );
}

5.3 Create app/[locale]/hola/page.tsx

import { useTranslations } from 'next-intl';

export default function HolaPage() {
  const t = useTranslations('HolaPage');

  return (
    <div style={{ padding: '2rem' }}>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
      <p>Current URL: <code>{typeof window !== 'undefined' ? window.location.pathname : 'Server'}</code></p>
    </div>
  );
}

5.4 Create app/[locale]/products/page.tsx

import { useTranslations } from 'next-intl';

export default function ProductsPage() {
  const t = useTranslations('ProductsPage');

  return (
    <div style={{ padding: '2rem' }}>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
      <p>Current URL: <code>{typeof window !== 'undefined' ? window.location.pathname : 'Server'}</code></p>
    </div>
  );
}

Step 6: Set Up Sentry Configuration Files

6.1 Create next.config.ts

import { withSentryConfig } from '@sentry/nextjs';
import { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';

const nextConfig: NextConfig = {
  // Add any Next.js config options here
};

const withNextIntl = createNextIntlPlugin({
  requestConfig: './i18n/request.ts',
});

export default withSentryConfig(withNextIntl(nextConfig), {
  org: "your-sentry-org",
  project: "your-sentry-project", 
  authToken: process.env.SENTRY_AUTH_TOKEN,
  silent: true,
});

6.2 Create instrumentation-client.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  integrations: [Sentry.replayIntegration()],
  tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
  replaysSessionSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
  replaysOnErrorSampleRate: 1.0,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

6.3 Create sentry.server.config.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
});

6.4 Create sentry.edge.config.ts

import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.05 : 1.0,
});

6.5 Create instrumentation.ts

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;

Step 7: Set Up Environment Variables

Create .env.local:

# Get these from your Sentry project settings
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/your-project-id
SENTRY_DSN=https://[email protected]/your-project-id
SENTRY_AUTH_TOKEN=your-auth-token-here

Step 8: Build and Run the Application

npm run build
npm run start

Step 9: Reproduce the Bug

9.1 Test English Browsing (Default Locale)

  1. Open browser to http://localhost:3000/
  2. Navigate to http://localhost:3000/hola
  3. Navigate to http://localhost:3000/products
  4. Check Sentry dashboard after 5-10 minutes

Expected: Transactions should be /, /hola, /products
Actual: All show as /:locale transaction

9.2 Test Arabic Browsing (Non-Default Locale)

  1. Open browser to http://localhost:3000/ar
  2. Navigate to http://localhost:3000/ar/hola
  3. Navigate to http://localhost:3000/ar/products
  4. Check Sentry dashboard after 5-10 minutes

Expected: Transactions should be /, /hola, /products
Actual: Shows as /:locale, /:locale/hola, /:locale/products

9.3 Test Navigation Issues

  1. Open browser to http://localhost:3000/
  2. Click on navigation links (don't use direct URL navigation)
  3. Switch languages using the EN/AR links
  4. Check Sentry dashboard

Expected: Each navigation should create Web Vitals data
Actual: Only page loads/refreshes create data, no navigation tracking

Expected Result

Expected Sentry Transaction Names:

  • Root page: / (regardless of locale)
  • Hola page: /hola (regardless of locale)
  • Products page: /products (regardless of locale)
  • Locale preserved as tags: i18n.locale: en or i18n.locale: ar

Actual Result

Actual Sentry Transaction Names:

  • English routes: /:locale (all pages show as same transaction)
  • Arabic routes: /:locale, /:locale/hola, /:locale/products
  • Sometimes duplicate transactions for same page
  • No Web Vitals data for client-side navigation

Additional Context

Additional Notes

  • The issue is most pronounced with localePrefix: "as-needed"
  • Changing to localePrefix: "always" may reduce the issue but breaks URL structure requirements in my project.
  • The problem affects both development and production builds
  • Console logging in Sentry hooks may show normalization attempts, but final dashboard (inside insights-> Frontend -> Web Vitals) still shows wrong names

This reproduction case should demonstrate the exact issues described in the bug report.

Metadata

Metadata

Assignees

No fields configured for issues without a type.

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions