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

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions