diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7a40cb69762..755f0b64d220 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,33 @@
## Unreleased
+### Important Changes
+
+- **feat(nextjs): Add Turbopack support for React component name annotation ([#19XXX](https://github.com/getsentry/sentry-javascript/pull/19XXX))**
+
+ We added experimental support for React component name annotation in Turbopack builds. When enabled, JSX elements
+ are annotated with `data-sentry-component`, `data-sentry-element`, and `data-sentry-source-file` attributes at build
+ time. This enables searching Replays by component name, seeing component names in breadcrumbs, and performance
+ monitoring — previously only available with webpack builds.
+
+ This feature requires Next.js 16+ and is currently behind an experimental flag:
+
+ ```js
+ // next.config.ts
+ import { withSentryConfig } from '@sentry/nextjs';
+
+ export default withSentryConfig(nextConfig, {
+ _experimental: {
+ turbopackReactComponentAnnotation: {
+ enabled: true,
+ ignoredComponents: ['Header', 'Footer'], // optional
+ },
+ },
+ });
+ ```
+
+### Other Changes
+
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
## 10.41.0
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx
new file mode 100644
index 000000000000..8ac6973dc5c8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/component-annotation/page.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import * as Sentry from '@sentry/nextjs';
+
+export default function ComponentAnnotationTestPage() {
+ return (
+
+ {
+ Sentry.captureException(new Error('component-annotation-test'));
+ }}
+ >
+ Click Me
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
index 342ba13b1206..41814b8152d0 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts
@@ -11,5 +11,8 @@ export default withSentryConfig(nextConfig, {
_experimental: {
vercelCronsMonitoring: true,
turbopackApplicationKey: 'nextjs-16-e2e',
+ turbopackReactComponentAnnotation: {
+ enabled: true,
+ },
},
});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts
new file mode 100644
index 000000000000..02e3a006bfdc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/component-annotation.test.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+
+const isWebpackDev = process.env.TEST_ENV === 'development-webpack';
+
+test('React component annotation adds data-sentry-component attributes (Turbopack)', async ({ page }) => {
+ test.skip(isWebpackDev, 'Only relevant for Turbopack builds');
+
+ await page.goto('/component-annotation');
+
+ const button = page.locator('#annotated-btn');
+ await expect(button).toBeVisible();
+
+ // Set up error listener before clicking
+ const errorPromise = waitForError('nextjs-16', errorEvent => {
+ return errorEvent?.exception?.values?.some(value => value.value === 'component-annotation-test') ?? false;
+ });
+
+ await button.click();
+ const errorEvent = await errorPromise;
+
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('component-annotation-test');
+
+ // In production, TEST_ENV=production is shared by both turbopack and webpack variants.
+ // The component annotation loader only runs in Turbopack builds, so use the independent
+ // turbopack tag (set by the SDK based on build metadata) to gate assertions rather than
+ // checking the feature's own output, which would silently pass on regression.
+ if (errorEvent.tags?.turbopack) {
+ const annotatedEl = page.locator('[data-sentry-component="ComponentAnnotationTestPage"]');
+ await expect(annotatedEl).toBeVisible();
+
+ const clickBreadcrumb = errorEvent.breadcrumbs?.find(bc => bc.category === 'ui.click');
+ expect(clickBreadcrumb?.data?.['ui.component_name']).toBe('ComponentAnnotationTestPage');
+ }
+});
diff --git a/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts b/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts
new file mode 100644
index 000000000000..b2b943302419
--- /dev/null
+++ b/packages/nextjs/src/config/loaders/componentAnnotationLoader.ts
@@ -0,0 +1,45 @@
+import { createComponentNameAnnotateHooks } from '@sentry/bundler-plugin-core';
+import type { LoaderThis } from './types';
+
+export type ComponentAnnotationLoaderOptions = {
+ ignoredComponents?: string[];
+};
+
+/**
+ * Turbopack loader that annotates React components with `data-sentry-component`,
+ * `data-sentry-element`, and `data-sentry-source-file` attributes.
+ *
+ * This is the Turbopack equivalent of what `@sentry/webpack-plugin` does
+ * via the `reactComponentAnnotation` option and `@sentry/babel-plugin-component-annotate`.
+ *
+ * Options:
+ * - `ignoredComponents`: List of component names to exclude from annotation.
+ */
+export default function componentAnnotationLoader(
+ this: LoaderThis,
+ userCode: string,
+): void {
+ const options = 'getOptions' in this ? this.getOptions() : this.query;
+ const ignoredComponents = options.ignoredComponents ?? [];
+
+ // We do not want to cache results across builds
+ this.cacheable(false);
+
+ const callback = this.async() ?? this.callback;
+
+ const hooks = createComponentNameAnnotateHooks(ignoredComponents, false);
+
+ hooks
+ .transform(userCode, this.resourcePath)
+ .then(result => {
+ if (result) {
+ callback(null, result.code, result.map);
+ } else {
+ callback(null, userCode);
+ }
+ })
+ .catch(() => {
+ // On error, pass through the original code gracefully
+ callback(null, userCode);
+ });
+}
diff --git a/packages/nextjs/src/config/loaders/index.ts b/packages/nextjs/src/config/loaders/index.ts
index 359d72d7def6..0ddd354f10fb 100644
--- a/packages/nextjs/src/config/loaders/index.ts
+++ b/packages/nextjs/src/config/loaders/index.ts
@@ -2,3 +2,4 @@ export { default as valueInjectionLoader } from './valueInjectionLoader';
export { default as prefixLoader } from './prefixLoader';
export { default as wrappingLoader } from './wrappingLoader';
export { default as moduleMetadataInjectionLoader } from './moduleMetadataInjectionLoader';
+export { default as componentAnnotationLoader } from './componentAnnotationLoader';
diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
index d8f70efbacf1..b6d963fc423e 100644
--- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
+++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts
@@ -79,6 +79,27 @@ export function constructTurbopackConfig({
});
}
+ // Add component annotation loader for react component name annotation in Turbopack builds.
+ // This is only added when turbopackReactComponentAnnotation.enabled is set AND the Next.js
+ // version supports the `condition` field in Turbopack rules (Next.js 16+).
+ const turbopackReactComponentAnnotation = userSentryOptions?._experimental?.turbopackReactComponentAnnotation;
+ if (turbopackReactComponentAnnotation?.enabled && nextJsVersion && supportsTurbopackRuleCondition(nextJsVersion)) {
+ newConfig.rules = safelyAddTurbopackRule(newConfig.rules, {
+ matcher: '*.{tsx,jsx}',
+ rule: {
+ condition: { not: 'foreign' },
+ loaders: [
+ {
+ loader: path.resolve(__dirname, '..', 'loaders', 'componentAnnotationLoader.js'),
+ options: {
+ ignoredComponents: turbopackReactComponentAnnotation.ignoredComponents ?? [],
+ },
+ },
+ ],
+ },
+ });
+ }
+
return newConfig;
}
diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts
index 233860fb1388..c79dad7e694e 100644
--- a/packages/nextjs/src/config/types.ts
+++ b/packages/nextjs/src/config/types.ts
@@ -724,6 +724,16 @@ export type SentryBuildOptions = {
* Requires Next.js 16+
*/
turbopackApplicationKey?: string;
+ /**
+ * Options for React component name annotation in Turbopack builds.
+ * When enabled, JSX elements are annotated with `data-sentry-component`,
+ * `data-sentry-element`, and `data-sentry-source-file` attributes.
+ * Requires Next.js 16+.
+ */
+ turbopackReactComponentAnnotation?: {
+ enabled?: boolean;
+ ignoredComponents?: string[];
+ };
}>;
/**
diff --git a/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts b/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts
new file mode 100644
index 000000000000..f12a49f8e24a
--- /dev/null
+++ b/packages/nextjs/test/config/loaders/componentAnnotationLoader.test.ts
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ComponentAnnotationLoaderOptions } from '../../../src/config/loaders/componentAnnotationLoader';
+import componentAnnotationLoader from '../../../src/config/loaders/componentAnnotationLoader';
+import type { LoaderThis } from '../../../src/config/loaders/types';
+
+const { mockTransform, mockCreateHooks } = vi.hoisted(() => {
+ const mockTransform = vi.fn();
+ const mockCreateHooks = vi.fn().mockReturnValue({ transform: mockTransform });
+ return { mockTransform, mockCreateHooks };
+});
+
+vi.mock('@sentry/bundler-plugin-core', () => ({
+ createComponentNameAnnotateHooks: mockCreateHooks,
+}));
+
+function createMockLoaderContext(
+ options: ComponentAnnotationLoaderOptions = {},
+ resourcePath = '/app/components/Button.tsx',
+): LoaderThis & { callback: ReturnType } {
+ const callback = vi.fn();
+ return {
+ resourcePath,
+ addDependency: vi.fn(),
+ cacheable: vi.fn(),
+ async: vi.fn().mockReturnValue(callback),
+ callback,
+ getOptions: vi.fn().mockReturnValue(options),
+ };
+}
+
+describe('componentAnnotationLoader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockTransform.mockReset();
+ mockCreateHooks.mockReturnValue({ transform: mockTransform });
+ });
+
+ it('calls this.async() and uses callback with transformed code and source map', async () => {
+ const mockResult = {
+ code: 'transformed code',
+ map: { version: 3, sources: ['Button.tsx'] },
+ };
+ mockTransform.mockResolvedValue(mockResult);
+
+ const ctx = createMockLoaderContext();
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(ctx.async).toHaveBeenCalled();
+ expect(ctx.callback).toHaveBeenCalledWith(null, 'transformed code', { version: 3, sources: ['Button.tsx'] });
+ });
+
+ it('passes through original code when transform returns null', async () => {
+ mockTransform.mockResolvedValue(null);
+
+ const ctx = createMockLoaderContext();
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(ctx.callback).toHaveBeenCalledWith(null, 'original code');
+ });
+
+ it('passes through original code on transform error', async () => {
+ mockTransform.mockRejectedValue(new Error('babel error'));
+
+ const ctx = createMockLoaderContext();
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(ctx.callback).toHaveBeenCalledWith(null, 'original code');
+ });
+
+ it('sets cacheable(false)', () => {
+ mockTransform.mockResolvedValue(null);
+
+ const ctx = createMockLoaderContext();
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ expect(ctx.cacheable).toHaveBeenCalledWith(false);
+ });
+
+ it('reads options via getOptions() (webpack 5)', async () => {
+ mockTransform.mockResolvedValue(null);
+
+ const ctx = createMockLoaderContext({ ignoredComponents: ['Header'] });
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockCreateHooks).toHaveBeenCalledWith(['Header'], false);
+ });
+
+ it('reads options via this.query (webpack 4)', async () => {
+ mockTransform.mockResolvedValue(null);
+
+ const callback = vi.fn();
+ const ctx = {
+ resourcePath: '/app/components/Button.tsx',
+ addDependency: vi.fn(),
+ cacheable: vi.fn(),
+ async: vi.fn().mockReturnValue(callback),
+ callback,
+ query: { ignoredComponents: ['Footer'] },
+ } as unknown as LoaderThis;
+
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockCreateHooks).toHaveBeenCalledWith(['Footer'], false);
+ });
+
+ it('defaults ignoredComponents to empty array', async () => {
+ mockTransform.mockResolvedValue(null);
+
+ const ctx = createMockLoaderContext({});
+ componentAnnotationLoader.call(ctx, 'original code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockCreateHooks).toHaveBeenCalledWith([], false);
+ });
+
+ it('passes resourcePath to transform', async () => {
+ mockTransform.mockResolvedValue(null);
+
+ const ctx = createMockLoaderContext({}, '/app/pages/Home.tsx');
+ componentAnnotationLoader.call(ctx, 'some code');
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockTransform).toHaveBeenCalledWith('some code', '/app/pages/Home.tsx');
+ });
+});
diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
index d1bf313d16f2..dacd0bd3857b 100644
--- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
+++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts
@@ -17,6 +17,9 @@ vi.mock('path', async () => {
if (lastArg === 'moduleMetadataInjectionLoader.js') {
return '/mocked/path/to/moduleMetadataInjectionLoader.js';
}
+ if (lastArg === 'componentAnnotationLoader.js') {
+ return '/mocked/path/to/componentAnnotationLoader.js';
+ }
return '/mocked/path/to/valueInjectionLoader.js';
}),
};
@@ -1080,6 +1083,182 @@ describe('moduleMetadataInjection with applicationKey', () => {
});
});
+describe('componentAnnotation with turbopackReactComponentAnnotation', () => {
+ it('should add component annotation loader rule when enabled and Next.js >= 16', () => {
+ const pathResolveSpy = vi.spyOn(path, 'resolve');
+ pathResolveSpy.mockImplementation((...args: string[]) => {
+ const lastArg = args[args.length - 1];
+ if (lastArg === 'componentAnnotationLoader.js') {
+ return '/mocked/path/to/componentAnnotationLoader.js';
+ }
+ if (lastArg === 'moduleMetadataInjectionLoader.js') {
+ return '/mocked/path/to/moduleMetadataInjectionLoader.js';
+ }
+ return '/mocked/path/to/valueInjectionLoader.js';
+ });
+
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackReactComponentAnnotation: { enabled: true },
+ },
+ },
+ nextJsVersion: '16.0.0',
+ });
+
+ expect(result.rules!['*.{tsx,jsx}']).toEqual({
+ condition: { not: 'foreign' },
+ loaders: [
+ {
+ loader: '/mocked/path/to/componentAnnotationLoader.js',
+ options: {
+ ignoredComponents: [],
+ },
+ },
+ ],
+ });
+ });
+
+ it('should NOT add component annotation rule when enabled is false', () => {
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackReactComponentAnnotation: { enabled: false },
+ },
+ },
+ nextJsVersion: '16.0.0',
+ });
+
+ expect(result.rules!['*.{tsx,jsx}']).toBeUndefined();
+ });
+
+ it('should NOT add component annotation rule when not set', () => {
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {},
+ nextJsVersion: '16.0.0',
+ });
+
+ expect(result.rules!['*.{tsx,jsx}']).toBeUndefined();
+ });
+
+ it('should NOT add component annotation rule when Next.js < 16', () => {
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackReactComponentAnnotation: { enabled: true },
+ },
+ },
+ nextJsVersion: '15.4.1',
+ });
+
+ expect(result.rules!['*.{tsx,jsx}']).toBeUndefined();
+ });
+
+ it('should NOT add component annotation rule when nextJsVersion is undefined', () => {
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackReactComponentAnnotation: { enabled: true },
+ },
+ },
+ nextJsVersion: undefined,
+ });
+
+ expect(result.rules!['*.{tsx,jsx}']).toBeUndefined();
+ });
+
+ it('should pass ignoredComponents to loader options', () => {
+ const pathResolveSpy = vi.spyOn(path, 'resolve');
+ pathResolveSpy.mockImplementation((...args: string[]) => {
+ const lastArg = args[args.length - 1];
+ if (lastArg === 'componentAnnotationLoader.js') {
+ return '/mocked/path/to/componentAnnotationLoader.js';
+ }
+ if (lastArg === 'moduleMetadataInjectionLoader.js') {
+ return '/mocked/path/to/moduleMetadataInjectionLoader.js';
+ }
+ return '/mocked/path/to/valueInjectionLoader.js';
+ });
+
+ const userNextConfig: NextConfigObject = {};
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackReactComponentAnnotation: {
+ enabled: true,
+ ignoredComponents: ['Header', 'Footer'],
+ },
+ },
+ },
+ nextJsVersion: '16.0.0',
+ });
+
+ const rule = result.rules!['*.{tsx,jsx}'] as {
+ condition: unknown;
+ loaders: Array<{ loader: string; options: { ignoredComponents: string[] } }>;
+ };
+ expect(rule.loaders[0]!.options.ignoredComponents).toEqual(['Header', 'Footer']);
+ });
+
+ it('should coexist with value injection and module metadata rules', () => {
+ const pathResolveSpy = vi.spyOn(path, 'resolve');
+ pathResolveSpy.mockImplementation((...args: string[]) => {
+ const lastArg = args[args.length - 1];
+ if (lastArg === 'componentAnnotationLoader.js') {
+ return '/mocked/path/to/componentAnnotationLoader.js';
+ }
+ if (lastArg === 'moduleMetadataInjectionLoader.js') {
+ return '/mocked/path/to/moduleMetadataInjectionLoader.js';
+ }
+ return '/mocked/path/to/valueInjectionLoader.js';
+ });
+
+ const userNextConfig: NextConfigObject = {};
+ const mockRouteManifest: RouteManifest = {
+ dynamicRoutes: [],
+ staticRoutes: [{ path: '/', regex: '/' }],
+ isrRoutes: [],
+ };
+
+ const result = constructTurbopackConfig({
+ userNextConfig,
+ userSentryOptions: {
+ _experimental: {
+ turbopackApplicationKey: 'my-app',
+ turbopackReactComponentAnnotation: { enabled: true },
+ },
+ },
+ routeManifest: mockRouteManifest,
+ nextJsVersion: '16.0.0',
+ });
+
+ // Value injection rules should be present
+ expect(result.rules!['**/instrumentation-client.*']).toBeDefined();
+ expect(result.rules!['**/instrumentation.*']).toBeDefined();
+ // Module metadata loader should be present
+ expect(result.rules!['*.{ts,tsx,js,jsx,mjs,cjs}']).toBeDefined();
+ // Component annotation loader should be present
+ expect(result.rules!['*.{tsx,jsx}']).toBeDefined();
+ });
+});
+
describe('safelyAddTurbopackRule', () => {
const mockRule = {
loaders: [