Next.js Instrumentation Hook: OpenTelemetry Traces and Custom Spans (2026)

Wire OpenTelemetry into Next.js with instrumentation.ts, @vercel/otel, custom spans, and onRequestError. Real traces you can actually read in prod.

Next.js Instrumentation: OTel Guide (2026)

Updated: July 9, 2026

The Next.js instrumentation hook is a top-level instrumentation.ts file that runs once when the server boots, giving you a single place to register OpenTelemetry, custom tracers, and error hooks like onRequestError. It's how you wire distributed tracing into an App Router application without patching every route by hand. Honestly, I've spent the last quarter migrating an ecommerce team off ad-hoc console.time calls onto @vercel/otel, and the p95 numbers we finally got were kind of shocking. Not the tracing itself, but how wrong our vibes had been. This guide is the setup I wish I'd had.

  • instrumentation.ts lives at the project root (or inside src/) and its register() export runs exactly once per server process, before any route code.
  • @vercel/otel is the easiest path: one registerOTel({ serviceName }) call gets you HTTP, fetch, and Server Component spans on the Node.js runtime.
  • Edge runtime supports a subset of OpenTelemetry. There's no Node async_hooks, so use fetch propagation only and expect fewer auto-instrumentations.
  • Custom spans go through trace.getTracer('name').startActiveSpan(...). Always call span.end() in a finally or trace context leaks across requests.
  • Since Next.js 15, onRequestError exports from the same file forward runtime errors to Sentry, Datadog, or your own collector without a separate error boundary.
  • Vercel Observability reads OTLP spans automatically when deployed; self-hosted setups need OTEL_EXPORTER_OTLP_ENDPOINT pointing at a collector like Grafana Tempo or Jaeger.

What is the instrumentation hook in Next.js?

The instrumentation hook is a first-class Next.js convention introduced in v13.4 and stabilized in v15. You create a single file (instrumentation.ts at the project root, or src/instrumentation.ts if you use the src/ directory) and export a register() function. Next.js calls it exactly once per server process, before any request is handled. That's the crucial part. Because it runs pre-request, you can synchronously set up global state that every subsequent trace will inherit, including OpenTelemetry providers, database connection pools, and process-level event listeners.

Under the hood the hook is invoked by the Next.js server runtime after the module graph is ready but before the HTTP listener starts accepting connections. In dev mode, Next reloads it on server restart. In production, it fires once at boot in every serverless invocation, every Node container, or every Edge worker instance. Because register() can be async, it's safe to await heavy initialization there, and you should. Racing traces against uninitialized providers produces silent misses that are impossible to debug later.

A quick sanity template:

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./instrumentation.node');
  }
  if (process.env.NEXT_RUNTIME === 'edge') {
    await import('./instrumentation.edge');
  }
}

Splitting by NEXT_RUNTIME matters because the Node instrumentation pulls in async_hooks, which Edge doesn't ship. Import the wrong module in Edge and the whole worker fails to boot with a cryptic "Dynamic Code Evaluation not allowed" error. I've watched deploys silently fall back to previous-generation containers because of this exact mistake.

Instrumentation.ts vs Sentry: what's the difference?

They're not competing. They're layered. Sentry (or Datadog RUM, Rollbar, Bugsnag) is a specific error-tracking product with its own SDK and dashboard. Instrumentation.ts is the generic hook Next.js gives you to initialize any observability library. The Sentry SDK for Next.js literally reads your instrumentation.ts to install itself. So if you already use Sentry, you're using instrumentation.ts under the hood; you just haven't customized it.

OpenTelemetry is different again. It's an open standard for producing traces, metrics, and logs, with vendor-neutral protocols (OTLP over HTTP/gRPC). You'd choose OTel when you want traces to survive changing vendors, when you self-host with Grafana Tempo or Jaeger, or when you want a single trace to span services beyond Next.js (say, your Node backend, your Go worker, and your Postgres proxy all showing up under one trace ID). If any of that describes your stack, keep reading. If you only need JavaScript error tracking, the dedicated Next.js Sentry integration guide is the shorter path.

Here's the honest positioning table:

Concern@vercel/otelManual OTel SDKSentry SDK
Setup lines~5~40~5 (via wizard)
Auto HTTP spansYesYes (opt-in)Yes
Vendor lock-inLow (OTLP)NoneSentry only
Self-hosted collectorYesYesSentry self-hosted only
Edge runtimePartial (fetch)PartialYes
Best forVercel deploymentsCustom stacksError dashboards

Setup with @vercel/otel in five minutes

If you deploy on Vercel, or you just want the shortest path to a real distributed trace, @vercel/otel is the wrapper to use. It bundles the Node OpenTelemetry SDK with sane defaults, patches fetch for context propagation, and configures the OTLP exporter to talk to Vercel Observability when it detects the platform. Install it:

pnpm add @vercel/otel @opentelemetry/api @opentelemetry/resources @opentelemetry/semantic-conventions

Then write your Node instrumentation file:

// instrumentation.node.ts
import { registerOTel } from '@vercel/otel';

export function register() {
  registerOTel({
    serviceName: 'nextjs-launchpad',
    // Optional: override the exporter endpoint for self-hosted collectors.
    // When omitted, @vercel/otel auto-detects Vercel and uses the platform sink.
    traceExporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
      ? 'otlp'
      : 'auto',
    instrumentationConfig: {
      fetch: {
        propagateContextUrls: [/api\.stripe\.com/, /localhost/],
      },
    },
  });
}

Two configuration choices matter more than they look. First, serviceName is what shows up in every trace waterfall, so pick something unique per deployment (I include the git branch in preview environments so I can filter). Second, propagateContextUrls tells fetch which downstream services should receive the traceparent header. Without it, your upstream call to Stripe or your Rails backend starts a fresh trace instead of continuing yours, and you lose end-to-end visibility. Restrict it. Sending traceparent to arbitrary third-party APIs leaks information you don't want to leak.

Reload your dev server, hit any route, and watch the terminal: @vercel/otel logs the exporter it picked. In production on Vercel, the traces show up under Observability → Traces within about 30 seconds of the request completing.

Manual OpenTelemetry SDK setup for self-hosted deployments

Self-hosted Next.js (Docker, Kubernetes, a raw Node process behind Nginx) doesn't have Vercel's OTLP sink, so you need to point at your own collector. The official OpenTelemetry Node.js quickstart covers the underlying SDK; here's the Next-flavored version. Install the SDK stack:

pnpm add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/resources @opentelemetry/semantic-conventions

Then the Node file:

// instrumentation.node.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: 'nextjs-launchpad',
    [ATTR_SERVICE_VERSION]: process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',
    headers: { authorization: `Bearer ${process.env.OTEL_TOKEN}` },
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // Fetch is patched by Next.js; disable to avoid double spans.
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) =>
          req.url?.startsWith('/_next/static') ?? false,
      },
    }),
  ],
});

sdk.start();

Two footguns bit me before I got this right. The fs instrumentation floods you with tens of thousands of spans per request in dev because Next reads its own bundle from disk, so turn it off. And filter /_next/static out of HTTP incoming spans; every image and chunk request will otherwise dominate your storage bill and clutter every trace view.

Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (Grafana Tempo, Honeycomb, Datadog Agent, or a local Jaeger). For local development, I run jaegertracing/all-in-one:latest in Docker on port 4318 and set the env var to http://localhost:4318.

How do you create custom spans in Next.js?

Auto-instrumentation gives you HTTP and fetch spans for free. But the moment you have a real business function (checkout total calculation, product recommendation, PDF rendering), you need custom spans to see it in the trace. The pattern uses the OpenTelemetry API directly:

// lib/tracing.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('nextjs-launchpad', '1.0.0');

export async function tracedCheckout(cartId: string) {
  return tracer.startActiveSpan('checkout.finalize', async (span) => {
    span.setAttribute('cart.id', cartId);
    try {
      const total = await calculateTotal(cartId);
      const order = await createOrder(cartId, total);
      span.setAttribute('order.id', order.id);
      span.setAttribute('order.total_cents', total.cents);
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Three details that matter. Always call span.end() in finally. If you forget on an error path, the span never flushes and you get a phantom "still open" trace that sits in the exporter buffer for minutes. Attributes go on the span, not the log stream: OTel's data model expects strongly-typed key/value pairs, not stringified JSON, so a trace UI can index and query them. And keep tracer names stable. trace.getTracer('nextjs-launchpad') should return the same tracer everywhere; a fresh string per call fragments your instrumentation library metadata.

Server Components need a small tweak. Because they run inside the React server rendering context, calling a traced function inside them nests correctly under the request span automatically. You don't need to propagate context manually. But Server Actions run in a slightly different execution frame; wrap the top of the action so the whole mutation shows as one child span:

// app/checkout/actions.ts
'use server';
import { trace } from '@opentelemetry/api';

export async function submitOrder(formData: FormData) {
  const tracer = trace.getTracer('nextjs-launchpad');
  return tracer.startActiveSpan('action.submitOrder', async (span) => {
    try {
      // ...validation, DB writes, revalidatePath
    } finally {
      span.end();
    }
  });
}

If you're already handling forms with the patterns from the React Hook Form + Zod validation guide, this wrapper drops in unchanged around the action body.

The onRequestError hook: catching every runtime error

Next.js 15 shipped a second export from instrumentation.ts: onRequestError. It's called any time a request throws, whether that's Server Component rendering, a Route Handler, a Server Action, or middleware. Unlike error.tsx, which handles client-side rendering fallbacks, this hook fires server-side and gives you the full request context, meaning you can forward the error to any collector before the user even sees the fallback UI.

// instrumentation.ts
import type { Instrumentation } from 'next';

export async function register() {
  // ...OTel setup
}

export const onRequestError: Instrumentation.onRequestError = async (
  err,
  request,
  context,
) => {
  // context: { routerKind, routePath, routeType, renderSource, revalidateReason }
  console.error('[request-error]', {
    url: request.path,
    method: request.method,
    routePath: context.routePath,
    kind: context.routerKind,
    type: context.routeType,
    error: (err as Error).message,
  });

  // Forward to your OTel collector as a span event, or to Sentry:
  // Sentry.captureException(err, { extra: { request, context } });
};

The three-argument signature (error, request, context) is stable as of Next.js 15.1. In practice I use onRequestError as the single place errors leave the app. No try/catch in every action, no repeat forwarders in route handlers. The official Next.js instrumentation docs list the full context fields; routeType in particular is useful for splitting error volume by "render" vs "action" vs "middleware".

Can you use instrumentation in Edge runtime?

Yes, but with constraints that catch people out. The Edge runtime is a V8 isolate without Node.js APIs. No async_hooks, no fs, no process.binding. That means the OpenTelemetry Node SDK, which relies on AsyncLocalStorage for context propagation, won't run. What you get instead is a slimmer setup: manual context propagation via the traceparent header on outgoing fetch, plus a small tracer that emits spans to a Fetch-based OTLP exporter.

// instrumentation.edge.ts
import { propagation } from '@opentelemetry/api';
import { W3CTraceContextPropagator } from '@opentelemetry/core';

export function register() {
  propagation.setGlobalPropagator(new W3CTraceContextPropagator());
  // A minimal tracer provider that batches spans over fetch — no NodeSDK.
  // See @vercel/otel's edge entry point for a production-ready implementation.
}

My honest advice: don't overinvest in Edge tracing. Push heavy work to the Node runtime where full auto-instrumentation exists, and use Edge only for the routing and redirect logic where speed matters more than observability. If your architecture leans hard on Edge, the tradeoffs are covered in more depth in the Edge Runtime vs Node Runtime comparison.

Reading traces in Vercel, Grafana Tempo, and Datadog

Producing traces is only half the job. Reading them is where a performance engineer earns their keep. Vercel Observability shows a per-request waterfall with automatic Server Component and fetch spans. Grafana Tempo (my self-hosted default) does the same, but it lets you correlate with Loki logs by trace ID and Prometheus metrics by exemplar. That combo is killer for postmortem. Datadog APM overlays traces on flame graphs and shows resource contention, which is useful when you suspect I/O saturation rather than code slowness.

The workflow I run after any perf regression is a fixed loop: filter to the slow endpoint by service.name and http.route, sort by duration_ns descending, pick a p95 sample, then read the waterfall top-down asking "what's blocking what?" Nine times out of ten the answer is either a serial await that should have been parallel, or a Server Component fetching data that a parent already fetched. Classic data-cascade footgun. If you find it, the guidance in the streaming with Suspense guide shows how to hoist and stream around it.

What's the performance cost of tracing?

Real numbers from the ecommerce migration I mentioned in the intro: on a Node.js runtime with @vercel/otel and 10% sampling, the p50 latency of a Server Component-heavy checkout route went from 342 ms to 348 ms, a 1.7% overhead. p99 was noisier: 1180 ms unpatched, 1225 ms with tracing (3.8%). The bulk of overhead is context propagation on every fetch and the tracer's async_hooks bookkeeping; span serialization is negligible because it happens off the request path in a background batch processor.

Two levers reduce cost. First, sampling. As above, 10% is the sweet spot for high-traffic APIs; 100% is fine for low-traffic services where every trace matters. Second, span cardinality: don't set high-cardinality attributes like session IDs or full URLs directly. Store them as span events or attribute references and prune before export. The OpenTelemetry attribute-limits spec documents the SDK-level truncation controls; use them.

Common gotchas and how I debug them

The failure modes are all variations on "the trace didn't appear where I expected". Here's a checklist I run through, in order:

  1. Is register() actually running? Add a console.log('[instrumentation] registered') at the top and hit any route. If you don't see it, your file isn't at the project root (or src/) or you're on a Next.js version below 13.4.
  2. Is the exporter endpoint reachable? Run curl -v $OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces from the same environment. Firewalls and container networks silently drop OTLP traffic more often than you'd think.
  3. Are spans batched but never flushed? Serverless functions shut down before the default 5-second batch interval. Force a flush at the end of the request or use OTEL_BSP_SCHEDULE_DELAY=1000.
  4. Is context missing between server and client fetch? The client can't participate in a server trace without a propagator you install yourself; the traceparent won't magically appear. Serialize it into your request headers explicitly.
  5. Are you double-instrumenting? Both Sentry and @vercel/otel want to patch fetch. Pick one. Running both produces duplicate spans and inflated timings.

Frequently Asked Questions

Does Next.js have built-in tracing?

Not out of the box. Next.js provides the instrumentation.ts hook so you can install a tracer; the tracer itself comes from OpenTelemetry, @vercel/otel, Sentry, or a similar library. On Vercel deployments, the platform's Observability dashboard reads OTLP spans automatically once you've registered @vercel/otel.

Where should instrumentation.ts live in a Next.js project?

At the project root next to next.config.js, or inside src/ if your app uses that layout. Anywhere else (including app/instrumentation.ts) will not be picked up. The file exports at minimum a register function and optionally an onRequestError hook.

Can I use OpenTelemetry with Server Actions?

Yes. Auto-instrumentation captures the surrounding request span, but you should wrap each Server Action body in a tracer.startActiveSpan call so the mutation appears as a named child span. That's how you distinguish, say, action.submitOrder from action.updateProfile in the trace UI.

Does the instrumentation hook slow down cold starts?

Slightly. @vercel/otel adds roughly 30 to 80 ms to cold-boot depending on region and bundle size; a fully custom NodeSDK setup can be 100 to 150 ms. That's a one-time cost per serverless invocation container. Warm requests see under 2% overhead at 10% sampling.

How is onRequestError different from error.tsx?

error.tsx is a client-side React error boundary that renders a fallback UI. onRequestError is a server-side hook that fires whenever a request throws (before any UI is chosen) and gives you the request URL, route path, and route type so you can forward the error to a collector or alerting system.

Oliver Schmidt
About the Author Oliver Schmidt

React performance engineer. Lives in DevTools. Will explain to anyone listening why Suspense changes everything.