Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f3b402e
Added Next.js App Router integration
BeltranBulbarellaDD Feb 3, 2026
c49fccf
Add plan and next steps for Next.js App Router integration
BeltranBulbarellaDD Feb 3, 2026
4d3dcaa
Add next to npmignore to fix duplicate package error
BeltranBulbarellaDD Feb 5, 2026
ba77231
Add test app for nextjs app router integration
BeltranBulbarellaDD Feb 5, 2026
73ad35f
Add e2e tests for Next.js App Router integration
BeltranBulbarellaDD Feb 6, 2026
5fc1a43
Remove unnecesary files.
BeltranBulbarellaDD Feb 6, 2026
876d2ab
Add Next.js to 3rdparty license.
BeltranBulbarellaDD Feb 6, 2026
e59db59
Merge branch 'main' into beltran.bulbarella/nextjs_appRouter_integration
BeltranBulbarellaDD Feb 6, 2026
77989b7
Fix yarn.lock.
BeltranBulbarellaDD Feb 6, 2026
1290421
Implement history tracking for loading time correct calculation
BeltranBulbarellaDD Feb 13, 2026
9554349
Merge branch 'main' into beltran.bulbarella/nextjs_appRouter_integration
BeltranBulbarellaDD Feb 13, 2026
9ed9c2c
Remove version from nextjs-app-router package.json
BeltranBulbarellaDD Feb 13, 2026
473b35c
remove unneded files and comments
BeltranBulbarellaDD Feb 16, 2026
c4f795b
Build Next.js app router test app, rename nextjs.scenario.
BeltranBulbarellaDD Feb 16, 2026
0a62c79
Fix web server config.
BeltranBulbarellaDD Feb 16, 2026
effabec
Use instrumentMethod from core for history tracking.
BeltranBulbarellaDD Feb 16, 2026
27a938e
Fix unit-bs by returning a cleanup and using initReactOldBrowsersSupport
BeltranBulbarellaDD Feb 17, 2026
2340fb2
Skip tearDownPassedTest for Next.js apps
BeltranBulbarellaDD Feb 17, 2026
7e6cd10
Replace historyTracking with usePathname and useParams
BeltranBulbarellaDD Feb 17, 2026
bedc27f
Merge branch 'main' into beltran.bulbarella/nextjs_appRouter_integration
BeltranBulbarellaDD Feb 17, 2026
cd1f691
Rebuild packages
BeltranBulbarellaDD Feb 17, 2026
d3e9871
Replace with mockable
BeltranBulbarellaDD Feb 17, 2026
22fffbb
Type typecheck issues because of https://github.com/microsoft/TypeScr…
BeltranBulbarellaDD Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ yarn.lock
/docs
/developer-extension/.output
/developer-extension/.wxt
/test/apps/nextjs-app-router/.next
/test/apps/nextjs-app-router/next-env.d.ts
1 change: 1 addition & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dev,karma-spec-reporter,MIT,Copyright 2015 Michael Lex
dev,karma-webpack,MIT,Copyright JS Foundation and other contributors
dev,lerna,MIT,Copyright 2015-present Lerna Contributors
dev,minimatch,ISC,Copyright (c) Isaac Z. Schlueter and Contributors
dev,next,MIT,Copyright (c) 2025 Vercel, Inc.
dev,node-forge,BSD,Copyright (c) 2010, Digital Bazaar, Inc.
dev,pako,MIT,(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin
dev,prettier,MIT,Copyright James Long and contributors
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default tseslint.config(
'docs',
'developer-extension/.wxt',
'developer-extension/dist',
'test/apps/nextjs-app-router',
],
},

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/instrumentMethod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ describe('instrumentSetter', () => {

it('does not use the Zone.js setTimeout function', () => {
const zoneJsSetTimeoutSpy = jasmine.createSpy()
zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy)
zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy as unknown as typeof setTimeout)

const object = {} as { foo: number }
Object.defineProperty(object, 'foo', { set: noop, configurable: true })
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/src/domain/tracing/identifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('toPaddedHexadecimalString', () => {
})

function mockRandomValues(cb: (buffer: Uint8Array) => void) {
spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView) => {
spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView: ArrayBufferView) => {
cb(new Uint8Array(bufferView.buffer))
return bufferView
})
Expand Down
1 change: 1 addition & 0 deletions packages/rum-react/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
/src/**/*.spec.ts
/src/**/*.specHelper.ts
!/react-router-v[6-7]/*
!/nextjs/*
7 changes: 7 additions & 0 deletions packages/rum-react/nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@datadog/browser-rum-react/nextjs",
"private": true,
"main": "../cjs/entries/nextjs.js",
"module": "../esm/entries/nextjs.js",
"types": "../cjs/entries/nextjs.d.ts"
}
5 changes: 5 additions & 0 deletions packages/rum-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@datadog/browser-rum-core": "6.27.1"
},
"peerDependencies": {
"next": ">=13",
"react": "18 || 19",
"react-router": "6 || 7",
"react-router-dom": "6 || 7"
Expand All @@ -25,6 +26,9 @@
"@datadog/browser-rum-slim": {
"optional": true
},
"next": {
"optional": true
},
"react": {
"optional": true
},
Expand All @@ -38,6 +42,7 @@
"devDependencies": {
"@types/react": "19.2.11",
"@types/react-dom": "19.2.3",
"next": "16.1.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-router": "7.13.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react'
import { replaceMockable } from '@datadog/browser-core/test'
import { usePathname, useParams } from 'next/navigation'
import { appendComponent } from '../../../test/appendComponent'
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { initReactOldBrowsersSupport } from '../../../test/reactOldBrowsersSupport'
import { DatadogRumProvider } from './datadogRumProvider'

describe('DatadogRumProvider', () => {
let startViewSpy: jasmine.Spy<(name?: string | object) => void>

beforeEach(() => {
initReactOldBrowsersSupport()
startViewSpy = jasmine.createSpy()
initializeReactPlugin({
configuration: {
nextjs: true,
},
publicApi: {
startView: startViewSpy,
},
})
})

it('renders children correctly', () => {
replaceMockable(usePathname, () => '/')
replaceMockable(useParams, () => ({}))

const container = appendComponent(
<DatadogRumProvider>
<div data-testid="test-child">Test Content</div>
</DatadogRumProvider>
)

const child = container.querySelector('[data-testid="test-child"]')
expect(child).not.toBeNull()
expect(child!.textContent).toBe('Test Content')
expect(child!.parentElement).toBe(container)
})

it('starts initial view on mount', () => {
replaceMockable(usePathname, () => '/home')
replaceMockable(useParams, () => ({}))

appendComponent(
<DatadogRumProvider>
<div>Content</div>
</DatadogRumProvider>
)

expect(startViewSpy).toHaveBeenCalledWith('/home')
})

it('starts initial view with normalized name using params', () => {
replaceMockable(usePathname, () => '/user/42')
replaceMockable(useParams, () => ({ id: '42' }))

appendComponent(
<DatadogRumProvider>
<div>Content</div>
</DatadogRumProvider>
)

expect(startViewSpy).toHaveBeenCalledWith('/user/:id')
})

it('renders multiple children', () => {
replaceMockable(usePathname, () => '/')
replaceMockable(useParams, () => ({}))

const container = appendComponent(
<DatadogRumProvider>
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
<div data-testid="child-3">Child 3</div>
</DatadogRumProvider>
)

expect(container.querySelector('[data-testid="child-1"]')!.textContent).toBe('Child 1')
expect(container.querySelector('[data-testid="child-2"]')!.textContent).toBe('Child 2')
expect(container.querySelector('[data-testid="child-3"]')!.textContent).toBe('Child 3')
})
})
31 changes: 31 additions & 0 deletions packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client'

import React, { type ReactNode, useEffect, useRef } from 'react'
import { usePathname, useParams } from 'next/navigation'
import { mockable } from '@datadog/browser-core'
import { computeViewName, startNextjsView } from './viewTracking'

export interface DatadogRumProviderProps {
/**
* The children components to render.
*/
children: ReactNode
}

export function DatadogRumProvider({ children }: DatadogRumProviderProps) {
const pathname = mockable(usePathname)()
const params = mockable(useParams)() ?? {}
const previousPathnameRef = useRef<string | undefined>(undefined)

useEffect(() => {
if (previousPathnameRef.current === pathname) {
return
}
previousPathnameRef.current = pathname

const viewName = computeViewName(pathname, params)
startNextjsView(viewName)
}, [pathname, params])

return <>{children}</>
}
3 changes: 3 additions & 0 deletions packages/rum-react/src/domain/nextjs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { DatadogRumProvider } from './datadogRumProvider'
export type { DatadogRumProviderProps } from './datadogRumProvider'
export { computeViewName } from './viewTracking'
98 changes: 98 additions & 0 deletions packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { display } from '@datadog/browser-core'
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { computeViewName, startNextjsView } from './viewTracking'

describe('computeViewName', () => {
;[
{ pathname: '/', params: {}, expected: '/' },
{ pathname: '/about', params: {}, expected: '/about' },
{ pathname: '/user/42', params: { id: '42' }, expected: '/user/:id' },
{
pathname: '/orders/456/items/789',
params: { orderId: '456', itemId: '789' },
expected: '/orders/:orderId/items/:itemId',
},
{
pathname: '/user/abc12345-1234-1234-1234-123456789012',
params: { id: 'abc12345-1234-1234-1234-123456789012' },
expected: '/user/:id',
},
{
pathname: '/user/abc12345-1234-1234-1234-123456789012/profile',
params: { id: 'abc12345-1234-1234-1234-123456789012' },
expected: '/user/:id/profile',
},
{
pathname: '/docs/a/b/c',
params: { slug: ['a', 'b', 'c'] },
expected: '/docs/:slug',
},
{
pathname: '/blog/my-awesome-post',
params: { slug: 'my-awesome-post' },
expected: '/blog/:slug',
},
].forEach(({ pathname, params, expected }) => {
it(`computes ${pathname} with params ${JSON.stringify(params)} to ${expected}`, () => {
expect(computeViewName(pathname, params)).toBe(expected)
})
})
})

describe('startNextjsView', () => {
let startViewSpy: jasmine.Spy<(name?: string | object) => void>

beforeEach(() => {
startViewSpy = jasmine.createSpy()
initializeReactPlugin({
configuration: {
nextjs: true,
},
publicApi: {
startView: startViewSpy,
},
})
})

it('creates a new view with the given view name', () => {
startNextjsView('/user/:id')

expect(startViewSpy).toHaveBeenCalledOnceWith('/user/:id')
})

it('warns when nextjs configuration is missing', () => {
const localStartViewSpy = jasmine.createSpy()
const warnSpy = spyOn(display, 'warn')
initializeReactPlugin({
configuration: {},
publicApi: {
startView: localStartViewSpy,
},
})

startNextjsView('/product/:id')

expect(warnSpy).toHaveBeenCalledOnceWith(
'`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.'
)
expect(localStartViewSpy).not.toHaveBeenCalled()
})

it('does not create a view when nextjs flag is false', () => {
const localStartViewSpy = jasmine.createSpy()
const warnSpy = spyOn(display, 'warn')
initializeReactPlugin({
configuration: {
nextjs: false,
},
publicApi: {
startView: localStartViewSpy,
},
})

startNextjsView('/product/:id')

expect(warnSpy).toHaveBeenCalled()
expect(localStartViewSpy).not.toHaveBeenCalled()
})
})
41 changes: 41 additions & 0 deletions packages/rum-react/src/domain/nextjs/viewTracking.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { display } from '@datadog/browser-core'
import { onRumInit } from '../reactPlugin'

/**
* Computes a view name by replacing dynamic parameter values in the pathname
* with their corresponding parameter names from Next.js's useParams().
*
* @example
* computeViewName('/user/42', { id: '42' }) // => '/user/:id'
* computeViewName('/docs/a/b/c', { slug: ['a', 'b', 'c'] }) // => '/docs/:slug'
*/
export function computeViewName(pathname: string, params: Record<string, string | string[] | undefined>): string {
let viewName = pathname
for (const [key, value] of Object.entries(params)) {
if (value === undefined) {
continue
}
if (Array.isArray(value)) {
viewName = viewName.replace(value.join('/'), `:${key}`)
} else {
viewName = viewName.replace(value, `:${key}`)
}
}
return viewName
}

/**
* Starts a new RUM view with the given view name.
*
* @internal
*/
export function startNextjsView(viewName: string) {
onRumInit((configuration, rumPublicApi) => {
if (!configuration.nextjs) {
display.warn('`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.')
return
}

rumPublicApi.startView(viewName)
})
}
9 changes: 8 additions & 1 deletion packages/rum-react/src/domain/reactPlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ describe('reactPlugin', () => {
const pluginConfiguration = { router: true }
const plugin = reactPlugin(pluginConfiguration)

expect(plugin.getConfigurationTelemetry()).toEqual({ router: true })
expect(plugin.getConfigurationTelemetry()).toEqual({ router: true, nextjs: false })
})

it('returns the configuration telemetry when nextjs is true', () => {
const pluginConfiguration = { nextjs: true }
const plugin = reactPlugin(pluginConfiguration)

expect(plugin.getConfigurationTelemetry()).toEqual({ router: false, nextjs: true })
})
})
11 changes: 9 additions & 2 deletions packages/rum-react/src/domain/reactPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface ReactPluginConfiguration {
* ```
*/
router?: boolean

/**
* Enable Next.js App Router integration. Make sure to use the DatadogRumProvider from
* {@link @datadog/browser-rum-react/nextjs! | @datadog/browser-rum-react/nextjs}
* to enable automatic view tracking.
*/
nextjs?: boolean
}

/**
Expand Down Expand Up @@ -61,7 +68,7 @@ export function reactPlugin(configuration: ReactPluginConfiguration = {}): React
for (const subscriber of onRumInitSubscribers) {
subscriber(globalConfiguration, globalPublicApi)
}
if (configuration.router) {
if (configuration.router || configuration.nextjs) {
initConfiguration.trackViewsManually = true
}
},
Expand All @@ -74,7 +81,7 @@ export function reactPlugin(configuration: ReactPluginConfiguration = {}): React
}
},
getConfigurationTelemetry() {
return { router: !!configuration.router }
return { router: !!configuration.router, nextjs: !!configuration.nextjs }
},
} satisfies RumPlugin
}
Expand Down
Loading