instrumentation-client.js

Runs monitoring and analytics code in the browser before React hydrates the page.

Signature
// No export is required — top-level code runs as-is.

export function onRouterTransitionStart(
  url: string,
  navigationType: 'push' | 'replace' | 'traverse',
  event?: RouterTransitionStartEvent
): void
  • Added in Next.js 15.3

Parameters

ParameterTypeDescription
$urlstringThe URL being navigated to.
$navigationType'push' | 'replace' | 'traverse'The type of navigation. 'traverse' covers browser back and forward.
$eventoptionalRouterTransitionStartEventExperimental third argument, delivered only when experimental.instrumentationClientRouterTransitionEvents is enabled. Carries id (an opaque id shared by events for this transition), timestamp (a framework-captured Unix time in ms), fromRoutes (route patterns visible before the navigation, primary children route first, then parallel slots in deterministic order) and prefetchIntent ('full', 'auto', 'none', or null for navigations with no associated link).

Return value

voidNothing. The file is evaluated for its side effects; onRouterTransitionStart is called for observation only and cannot cancel or alter a navigation.

Errors

Nothing that reaches your app — errors thrown from onRouterTransitionStart are isolated and do not affect navigation or other hooks.

Changelog

  • PHP 16.3.0Experimental router transition start event introduced as an optional third argument to onRouterTransitionStart.

Unlike its server counterpart, this file needs no exports at all: whatever you write at the top level runs as-is. It executes after the HTML document loads, before React hydration begins, and before the user can interact — the earliest hook you get in the browser, which is exactly what error trackers and analytics agents want so they can capture failures that happen during hydration itself.

When to reach for it

  • Initializing an error tracker so it catches hydration errors, not just post-mount ones.
  • Recording navigation breadcrumbs through onRouterTransitionStart.
  • Applying a polyfill that components need before they first render.

What trips people up

Only synchronous, top-level code is guaranteed to finish before hydration. A Promise, a dynamic import() or a top-level await is not awaited by Next.js, so a polyfill loaded that way can land after your components already ran. Import it statically and apply it synchronously after feature detection.

Keep the file small: Next.js times its initialization in development and warns when it exceeds 16ms. Wrap each tool's setup in its own try/catch so one failing agent does not take the rest down with it.

Example

Example · typescript
import Monitor from './lib/monitoring'

// Runs before React hydration, so hydration errors are captured too.
try {
  Monitor.initialize()
  performance.mark('app-init')
} catch (error) {
  console.error('monitoring failed to start', error)
}

export function onRouterTransitionStart(
  url: string,
  navigationType: 'push' | 'replace' | 'traverse'
) {
  Monitor.pushEvent({
    message: `Navigation to ${url}`,
    category: 'navigation',
    data: { navigationType },
  })
}

When to use it

  • Start an error tracker early enough to capture hydration failures.
  • Send a breadcrumb to your analytics tool on every client-side route change.
  • Mark app-init with the Performance API to measure time to interactive.
  • Install a ResizeObserver polyfill before any component mounts.

Frequently asked questions

When does instrumentation-client.js run?

After the HTML document is loaded, before React hydration begins, and before user interactions are possible. That ordering is what makes it suitable for error tracking that must cover hydration itself.

Do I need to export anything from instrumentation-client.js?

No. Unlike server-side instrumentation, no specific export is required — top-level code runs directly. The one optional export is onRouterTransitionStart, for observing App Router navigations.

Why is my top-level await in instrumentation-client not finishing before hydration?

Only synchronous, top-level code is guaranteed to complete before hydration; Next.js does not await promises, dynamic imports or top-level await here. Treat async work as fire-and-forget and use a static import when something must be in place first.

More examples

Polyfilling correctly, and the version that silently fails

Next.js does not await asynchronous work started in this file. The commented-out version type-checks, ships, and works most of the time on a fast connection — then fails on a slow one, because hydration begins before the dynamic chunk arrives. The static import costs every visitor the polyfill bytes, which is the price of the guarantee.

Example · typescript
// Correct: static import, applied synchronously after feature detection.
import ResizeObserverPolyfill from './lib/polyfills/resize-observer'

if (!window.ResizeObserver) {
  window.ResizeObserver = ResizeObserverPolyfill
}

// Avoid: the dynamic import is fire-and-forget, so `ResizeObserver`
// may still be undefined when your components run.
// if (!window.ResizeObserver) {
//   import('./lib/polyfills/resize-observer').then((mod) => {
//     window.ResizeObserver = mod.default
//   })
// }

Timing navigations with the experimental event argument

The third argument only arrives when experimental.instrumentationClientRouterTransitionEvents is set to true in next.config.ts; without the flag it is undefined and destructuring it throws. fromRoutes holds filesystem-style route patterns, so leaving /blog/hello reports '/blog/[slug]'. prefetchIntent is null for router.push() and for browser back/forward, since no link prefetch intent applies.

Example · typescript
import type { RouterTransitionStartEvent, RouterTransitionType } from 'next'

export function onRouterTransitionStart(
  url: string,
  navigationType: RouterTransitionType,
  { id, timestamp, fromRoutes, prefetchIntent }: RouterTransitionStartEvent
) {
  console.log(id, timestamp, url, navigationType, fromRoutes, prefetchIntent)
  // e.g. 'a1b2' 1750000000000 '/blog/hello' 'push' ['/blog'] 'auto'
}

Reporting navigation performance

The observer is registered before hydration, so it catches the initial navigation entry that a component-level useEffect would miss. The mark name embeds the URL, which keeps client-side transitions distinguishable in the performance timeline; measure against it in a route-change completion handler.

Example · typescript
const startTime = performance.now()

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry instanceof PerformanceNavigationTiming) {
      console.log('Time to Interactive:', entry.loadEventEnd - startTime)
    }
  }
})

observer.observe({ entryTypes: ['navigation'] })

export function onRouterTransitionStart(url: string) {
  performance.mark(`nav-start-${url}`)
}

Discussion

  • Be the first to comment on this lesson.