
Next.js Setting up instrumentation
Every production Next.js app eventually hits the same wall: something breaks in a way you can't reproduce locally, and by the time you find out about it from a user complaint, the request that triggered it is long gone. Console logs don't survive a serverless cold start. console.error in a Route Handler doesn't page anyone. And by default, Next.js doesn't know you want to be told when something goes wrong on the server — it just handles the error and moves on.
Instrumentation is how you close that gap. It's the mechanism Next.js gives you to hook into the server's lifecycle — both when it starts up, and when a request fails — so you can wire in monitoring, tracing, and error reporting without scattering try/catch blocks across your entire codebase. Done right, it's mostly invisible: a couple of exported functions in one file, and suddenly every error in your app flows into Sentry, Datadog, Honeycomb, or wherever your team actually looks when something's on fire.
This guide walks through both halves of the instrumentation file — the startup hook (register) and the error hook (onRequestError) — what each one is actually for, where people get tripped up, and how to wire up a realistic observability setup rather than just the toy example from the docs.
What "instrumentation" means here, specifically
If you've worked with APM tools before, "instrumentation" usually means auto-instrumentation — an agent that patches your HTTP client, your database driver, and your framework's request handling at runtime, without you writing any code. Datadog's Node agent does this. So does most of the OpenTelemetry ecosystem when you use the auto-instrumentation packages.
Next.js's instrumentation file is a different, narrower thing: it's a convention for running your own setup code exactly once, at the moment a server instance boots, plus a hook that fires when a server-side error happens. It's not auto-instrumentation itself — it's the doorway you use to install auto-instrumentation, or to run whatever manual setup your observability vendor's SDK asks for.
In other words: instrumentation.ts is where you put "the thing that has to happen before my app starts serving traffic," and onRequestError is where you put "the thing that should happen every time a server-side error occurs." Everything else about how your tracing/monitoring tool actually works is up to that tool, not Next.js.
The file convention
To opt in, create an instrumentation.ts (or .js) file in the root of your project — the same level as your package.json, not inside app/ or pages/. If your project uses a src directory, it goes inside src/, next to app and pages, not inside either of them.
my-app/
├── src/
│ ├── app/
│ ├── instrumentation.ts ← here, if using src/
├── instrumentation.ts ← or here, if not using src/
└── next.config.js
This trips people up more often than it should. If you've customized pageExtensions in next.config.js to add a suffix — say, all your page files end in .page.tsx — you need to rename this file to match that suffix too (instrumentation.page.ts), or Next.js won't pick it up. It's an easy thing to forget six months after you set pageExtensions, and the failure mode is silent: your instrumentation file just never runs, and nothing tells you why.
register(): code that runs once, before anything else
The core export is a function called register. Next.js calls it exactly once, when a new server instance starts, and — critically — it must finish before the server accepts any requests. If register is async and does something slow, your cold start gets slower. That's a real cost, not a theoretical one, especially on serverless platforms where cold starts already dominate your p99 latency.
The canonical example, wiring up OpenTelemetry via Vercel's helper package:
// instrumentation.ts
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel("next-app");
}
That's genuinely almost all you need if you're using Vercel's hosted OpenTelemetry pipeline. But most teams aren't on Vercel, or want a different backend (Honeycomb, Datadog, self-hosted Jaeger), and that's where register earns its keep — it's just a function, so it can do anything a normal async function can do: read environment variables, initialize an SDK, open a connection, register a global error handler.
Side-effect imports: import inside the function, not at the top
Sometimes the thing you need to run isn't a function call — it's just importing a module for what it does when it's imported. Sentry's Node SDK is the classic case: you call Sentry.init() and from then on it patches things globally.
The docs recommend doing this import inside register, not as a top-level import statement:
// instrumentation.ts
export async function register() {
await import("./sentry.server.config");
}
This isn't just a style preference — it matters for two concrete reasons. First, a top-level import runs the moment the module is loaded, which in some bundling/runtime configurations can happen earlier or more than once in ways you don't control; a dynamic import inside register guarantees it runs exactly when Next.js calls register, once. Second, instrumentation.ts runs in every runtime Next.js supports — Node.js and Edge — and plenty of observability SDKs are Node-only (they use fs, net, or other Node built-ins that don't exist in the Edge runtime). If you import one of those at the top of the file unconditionally, you can break Edge middleware or Edge API routes that have nothing to do with your instrumentation, because the whole file fails to evaluate in that runtime. A dynamic import you only reach conditionally avoids that entirely.
Runtime-specific code: NEXT_RUNTIME
Because register runs in both Node.js and Edge, you need a way to branch on which one you're currently in. Next.js exposes this as an environment variable, not a function argument:
// 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");
}
}
Split your setup into two files like this any time your tooling isn't Edge-safe — which, in practice, is most traditional APM SDKs. The Edge runtime is a stripped-down V8 isolate, closer to a Cloudflare Worker than to Node, so anything relying on Node's http, fs, worker threads, or native addons simply isn't available there.
onRequestError: the hook most people miss
register gets all the attention in tutorials because it's the one with the flashy OpenTelemetry one-liner. But onRequestError is arguably the more valuable export for day-to-day operations, because it's your hook into every server-side error Next.js catches — Server Components, Route Handlers, Server Actions, and Proxy — regardless of what caused it.
import { type Instrumentation } from "next";
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://your-error-collector.example.com/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, digest, request, context }),
});
};
The three parameters carry more useful information than most people realize on first read:
error: unknown. Not Error. This is a deliberate, important detail: the value React hands you here might not be the exact object that was originally thrown. If an error occurs during Server Component rendering, React can wrap or transform it before it reaches your hook. That's why the docs push you toward the digest property instead of trusting message alone — digest is a stable identifier Next.js attaches to the error, and it's the thing you actually want to correlate against the "Application error: a server-side exception has occurred" message your users see, which includes that same digest.
request gives you the resource path, HTTP method, and headers — read-only, and genuinely useful for grouping errors by route or spotting a bad deploy that's failing on one specific endpoint.
context is where the real diagnostic value is, and it's mostly undocumented territory once you get past the type signature:
routerKind:'Pages Router'or'App Router'— useful if you're mid-migration and want to know which router a failure came from.routeType:'render' | 'route' | 'action' | 'proxy'— was this a page render, a Route Handler, a Server Action, or your Proxy file? This alone will save you time triaging: an error withrouteType: 'action'means a form submission or mutation broke, which is a very different incident than a'render'failure on a marketing page.renderSource: tells you whether the failure happened during the initial RSC render, while serializing the RSC payload, or during traditional server-side HTML rendering.revalidateReason:'on-demand' | 'stale' | undefined. If this is set, the error happened during a background revalidation, not during a live user request — which changes how urgently you should treat it. Anundefinedhere means it was a normal, user-facing request.renderType:'dynamic' | 'dynamic-resume'. The'dynamic-resume'value is specific to Partial Prerendering / Cache Components — it means the error happened while resuming the dynamic portion of an already-streamed shell, not during the initial request.
None of this replaces error.tsx. error.tsx is a UI concern — it decides what the user sees when something breaks. onRequestError is a reporting concern — it decides what you find out, and it fires independently of whatever error boundary catches the failure for the visitor. You want both: a friendly error.tsx for the person looking at the broken page, and onRequestError quietly shipping the same failure to your monitoring stack in the background.
A realistic Sentry setup, end to end
Here's what a fuller, production-shaped setup looks like rather than the single-function snippet the docs show:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export async function onRequestError(err, request, context) {
const Sentry = await import("@sentry/nextjs");
Sentry.captureRequestError(err, request, context);
}
// sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
});
Two things worth calling out that a first pass at this code tends to get wrong. First, Sentry.init() happens in a separate file that's dynamically imported, not inline in instrumentation.ts — that keeps the Node-only Sentry SDK out of the Edge runtime's module graph entirely, since it's only reachable through the NEXT_RUNTIME === 'nodejs' branch. Second, onRequestError awaits the dynamic import too; skipping that await is a subtle bug, because if the import hasn't resolved by the time you try to call Sentry.captureRequestError, you'll get a runtime error trying to call a method on an unresolved promise.
Practical notes the docs don't spell out
Always await async work inside onRequestError. The docs mention this in passing, but it's worth understanding why it matters so much here specifically: on serverless platforms, the function execution environment can be frozen or torn down the moment your handler returns, before an unawaited fetch() call actually completes. If you fire off a webhook to your error collector without awaiting it, you'll intermittently lose error reports — and you'll never notice, because the errors that do get reported look fine, and the ones that get dropped leave no trace at all. This is one of the most common "why is my error rate lower in Sentry than what users are reporting" bugs.
onRequestError only sees server-side errors. It has zero visibility into client-side exceptions — an error thrown inside a 'use client' component's event handler, or a failed fetch call made from the browser, never reaches this hook. For client-side error tracking you still need your APM SDK's browser integration, typically wired up in instrumentation-client.ts (a separate, newer convention) or a top-level error boundary. Don't assume this one file gives you full-stack error coverage — it gives you the server half.
Register runs per instance, and "instance" means different things on different platforms. On a traditional long-running Node server, register runs once at process start and stays warm indefinitely. On a serverless platform, a "server instance" can be a single Lambda invocation's execution environment, and cold starts happen far more often than most people expect under variable traffic — meaning register might run far more often than "once per deploy." If your register function does anything expensive (spinning up a database connection pool, for example), that cost gets paid repeatedly, not amortized. Keep register fast, and prefer lazy connection strategies over eager ones for anything serverless-hosted.
This feature graduated relatively recently — check your version if something doesn't match. instrumentation shipped experimentally in v13.2.0, got Turbopack support in v14.0.4, and onRequestError plus general stability landed together in v15.0.0. If you're reading a blog post (including an older version of this one) or Stack Overflow answer that predates v15, assume onRequestError doesn't exist yet in whatever it's describing.
Local testing is easy to skip and shouldn't be. Because register only runs once per server start, the fastest way to verify your setup during development is to add a temporary console.log inside it and restart next dev — if you don't see the log, the file isn't being picked up (check the root-vs-src placement and the pageExtensions suffix first). To test onRequestError, the simplest reliable trick is a throwaway Route Handler that throws unconditionally, hit once with curl, then deleted.
Common mistakes
- Placing
instrumentation.tsinsideapp/instead of the project root orsrc/— it silently does nothing. - Forgetting to rename the file when a custom
pageExtensionssuffix is configured. - Importing a Node-only SDK at the top level of
instrumentation.tswithout gating it behindNEXT_RUNTIME === 'nodejs', which can break Edge-runtime routes elsewhere in the app. - Firing off reporting calls in
onRequestErrorwithoutawait-ing them, silently losing error reports on serverless platforms. - Treating
onRequestErroras a substitute forerror.tsx(or vice versa) — they solve different problems and you generally want both. - Assuming
onRequestErrorreports client-side errors — it doesn't.
Key Takeaways
| Concern | What to do |
|---|---|
| Where does the file live? | Project root, or inside src/ if you use one — never inside app/ or pages/ |
| Running setup once at boot | Export register(); keep it fast, it blocks the server from accepting requests |
| Node-only SDKs | Dynamically import() them inside register, gated on process.env.NEXT_RUNTIME === 'nodejs' |
| Catching server errors for reporting | Export onRequestError(error, request, context) |
| Trusting the error message | Prefer error.digest over error.message — React may have transformed the original error |
| Knowing what kind of request failed | Read context.routeType (render / route / action / proxy) and context.revalidateReason |
Async work inside onRequestError | Always await it, or serverless platforms may tear down before it completes |
| User-facing error UI | Still needs error.tsx — onRequestError is for your monitoring stack, not your visitors |
| Client-side errors | Not covered by this file at all — needs your APM's browser SDK separately |
Instrumentation is one of those Next.js features that takes ten minutes to wire up and pays for itself the first time it tells you about a production bug before a user does. The register half gets you tracing; the onRequestError half gets you a reliable feed of exactly what broke, where, and under what conditions — which, in the middle of an incident, is worth far more than a stack trace with no context attached to it.


