instrumentation.js

Runs setup code once when the server boots and reports server errors to an observability provider.

Signature
export function register(): void | Promise<void>

export function onRequestError(
  error: unknown,
  request: {
    path: string
    method: string
    headers: { [key: string]: string | string[] }
  },
  context: {
    routerKind: 'Pages Router' | 'App Router'
    routePath: string
    routeType: 'render' | 'route' | 'action' | 'proxy'
    renderSource:
      | 'react-server-components'
      | 'react-server-components-payload'
      | 'server-rendering'
    revalidateReason: 'on-demand' | 'stale' | undefined
    renderType: 'dynamic' | 'dynamic-resume'
  }
): void | Promise<void>
  • Added in Next.js 13.2.0

Parameters

ParameterTypeDescription
$errorunknownThe caught value, typed as unknown. It may not be the original thrown instance — React can reprocess errors raised while rendering Server Components, so narrow it and read digest to identify the real error.
$request{ path: string; method: string; headers: { [key: string]: string | string[] } }Read-only request information associated with the error: the resource path including its query string, the HTTP method, and the request headers.
$context{ routerKind: 'Pages Router' | 'App Router'; routePath: string; routeType: 'render' | 'route' | 'action' | 'proxy'; renderSource: 'react-server-components' | 'react-server-components-payload' | 'server-rendering'; revalidateReason: 'on-demand' | 'stale' | undefined; renderType: 'dynamic' | 'dynamic-resume' }Where the error happened: which router served it, the route file path such as /app/blog/[dynamic], and whether it came from rendering, a Route Handler, a Server Action or the proxy. revalidateReason is undefined for a normal request.

Return value

void | Promise<void>Nothing. Both exports may be async; register must settle before the server accepts requests, and a promise returned from onRequestError is awaited.

Errors

Nothing of its own, but an uncaught error inside register surfaces at startup because register must complete before the server is ready to handle requests.

Changelog

  • PHP 15.0.0onRequestError introduced and instrumentation marked stable.
  • PHP 14.0.4Turbopack support for instrumentation.

register is the only documented place to run code once per server instance, before the first request is served. That makes it the home for tracing SDK setup, database connection pools, feature-flag clients and anything else that must exist before route code executes. It is called for every new server instance, so in a multi-worker deployment it runs once per worker, not once per deploy.

When to reach for it

  • Wiring OpenTelemetry, Sentry or a similar agent that must patch modules before they are imported by routes.
  • Shipping server errors to a provider that has no Next.js integration, using onRequestError.

What trips people up

The file must sit at the project root — beside app, or inside src if you use one — not inside app. It runs in both the Node.js and Edge runtimes, so importing a Node-only package at the top level breaks the Edge build; branch on process.env.NEXT_RUNTIME and require the runtime-specific module instead.

In onRequestError, error is typed unknown and may not be the instance you threw. Narrow it before touching message, and read the digest property to correlate it with the error the client saw. Any async work must be awaited, or the process can exit before your report is sent.

Example

Example · typescript
import { registerOTel } from '@vercel/otel'
import { type Instrumentation } from 'next'

export function register() {
  registerOTel('next-app')
}

export const onRequestError: Instrumentation.onRequestError = async (
  err,
  request,
  context
) => {
  const message = err instanceof Error ? err.message : String(err)
  const digest =
    typeof err === 'object' && err !== null && 'digest' in err
      ? String(err.digest)
      : undefined

  await fetch('https://errors.internal/report', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message,
      digest,
      path: request.path,
      method: request.method,
      routePath: context.routePath,
      routeType: context.routeType,
    }),
  })
}

When to use it

  • Initialize an OpenTelemetry tracer before any route handler runs.
  • Forward every server-side render error to a self-hosted error tracker.
  • Load Node-only agents in the Node runtime while keeping the Edge bundle clean.
  • Warm a connection pool once per server instance instead of per request.

Frequently asked questions

Where does instrumentation.ts go in a Next.js project?

At the project root, beside the app or pages directory, or inside src/ if your application code lives there. It is not picked up from inside app/, and there is no config option to move it.

Does register() run on every request?

No. It is called once when a new Next.js server instance is initiated and must complete before that instance handles requests. On platforms that spin up multiple workers or serverless instances, it runs once per instance.

Why is the error in onRequestError not the error I threw?

React can reprocess an error encountered while rendering Server Components, so the instance you receive may be a stand-in. Use the digest property on the error to identify the actual error type and correlate it with what the client saw.

More examples

Splitting Node.js and Edge setup

instrumentation.ts is bundled for both runtimes, so a top-level `import fs from 'node:fs'` would break the Edge build even though only the Node path uses it. Deferring with require() inside the branch keeps each bundle free of the other runtime's dependencies. NEXT_RUNTIME is 'edge' or 'nodejs'.

Example · typescript
export function register() {
  if (process.env.NEXT_RUNTIME === 'edge') {
    return require('./register.edge')
  } else {
    return require('./register.node')
  }
}

export function onRequestError() {
  if (process.env.NEXT_RUNTIME === 'edge') {
    return require('./on-request-error.edge')
  } else {
    return require('./on-request-error.node')
  }
}

Routing errors by where they came from

context.routePath is the route file pattern, not the visited URL — a request to /blog/hello reports /app/blog/[slug]. That is what you group alerts by, while request.path is the concrete URL you use to reproduce. revalidateReason is undefined on a normal request and 'stale' when the error surfaced during background revalidation, which is usually not worth paging anyone about.

Example · typescript
import { type Instrumentation } from 'next'

export const onRequestError: Instrumentation.onRequestError = async (
  err,
  request,
  context
) => {
  // Server Action failures are user-facing; render failures are page-wide.
  const severity = context.routeType === 'action' ? 'warning' : 'error'

  // Skip noise from background revalidation of stale pages.
  if (context.revalidateReason === 'stale') return

  await reportToProvider({
    severity,
    router: context.routerKind, // 'App Router'
    route: context.routePath, // '/app/blog/[slug]'
    url: request.path, // '/blog/hello?ref=x'
    message: err instanceof Error ? err.message : String(err),
  })
}

async function reportToProvider(payload: Record<string, unknown>) {
  await fetch('https://errors.internal/report', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
}

Awaiting async work so the report is not dropped

onRequestError is awaited by Next.js when it returns a promise, so awaiting a flush inside it is the documented way to guarantee delivery. Fire-and-forget calls — captureException(err) with no await — routinely go missing on serverless platforms that freeze the instance the moment the response is sent.

Example · typescript
import { type Instrumentation } from 'next'
import { flush, captureException } from './lib/tracker'

export function register() {
  // Runs once per server instance, before any request is handled.
  console.log('instrumentation registered')
}

export const onRequestError: Instrumentation.onRequestError = async (err) => {
  captureException(err)
  // Without this await the buffered event can be lost on shutdown.
  await flush(2000)
}

Discussion

  • Be the first to comment on this lesson.