
Next.js instrumentation.js
instrumentation.js is one of the smallest files in the App Router's convention vocabulary and one of the most disproportionately useful once you actually need it. It's the one place in a Next.js app where you can reliably run code exactly once, when a server instance starts, before that instance accepts a single request — plus an optional hook that gives you a structured feed of every server-side error the framework catches, complete with routing context you'd otherwise have to reconstruct by hand.
This is a compact reference: two exported functions, a handful of parameters, and one runtime-targeting trick. What makes it worth reading closely is exactly what each function guarantees and doesn't guarantee, since both of those boundaries matter for building monitoring you can actually trust.
File Location
Place instrumentation.ts (or .js) in the root of your project, or inside your src folder if your project uses one — the same top-level location as next.config.js, not nested inside app. It's a project-level concern, not a route-level one, and its placement reflects that.
register() — Runs Once, Before Anything Else
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel("next-app");
}
The contract here is precise and worth internalizing exactly: register is called once, when a new Next.js server instance is initiated, and it must complete before the server is ready to handle requests. register can be async, and Next.js will wait for it.
That "must complete before ready" guarantee is the entire reason this file exists instead of you just importing your tracing SDK setup somewhere convenient in application code. Observability tooling — OpenTelemetry exporters, error-reporting SDKs, feature-flag clients that need an initial fetch — generally needs to be fully initialized before the first real request arrives, or you risk losing exactly the earliest events you'd most want visibility into (cold-start errors, first-request latency spikes). register() gives you that guarantee structurally, rather than hoping your import order happens to work out.
onRequestError() — A Structured Feed of Server Errors
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://.../report-error", {
method: "POST",
body: JSON.stringify({ message, digest, request, context }),
headers: { "Content-Type": "application/json" },
});
};
Two behavioral details here are easy to get wrong if you skim past them:
If you run async work inside onRequestError, you must await it. The function is triggered when the Next.js server captures an error — it doesn't wait around for orphaned fire-and-forget promises you started and walked away from. If your reporting call isn't awaited, there's no guarantee it completes before the process moves on, and you'll lose error reports intermittently in a way that's maddening to debug because it looks random.
The error instance you receive might not be the original thrown error. If the error was encountered during Server Component rendering, React may have processed it before it reaches you — meaning the object here can differ from what your code actually threw. This is exactly why the digest property exists: use it as your stable correlation key to match this callback's error against the corresponding server-side log entry, rather than relying on message staying consistent.
The Three Parameters in Full
export function onRequestError(
error: unknown,
request: {
path: string; // resource path, e.g. /blog?name=foo
method: string; // request method, e.g. GET, POST
headers: { [key: string]: string | string[] };
},
context: {
routerKind: "Pages Router" | "App Router";
routePath: string; // e.g. /app/blog/[dynamic]
routeType: "render" | "route" | "action" | "proxy";
renderSource:
| "react-server-components"
| "react-server-components-payload"
| "server-rendering";
revalidateReason: "on-demand" | "stale" | undefined;
renderType: "dynamic" | "dynamic-resume"; // 'dynamic-resume' is PPR
},
): void | Promise<void>;
The context object is doing a lot of quiet, useful work here. routeType alone tells you whether an error came from rendering a page, handling a Route Handler request, running a Server Action, or executing your Proxy — four fundamentally different code paths that, without this context object, you'd otherwise have to infer from stack traces or guess at. renderSource distinguishes an error during the initial RSC render from one during a subsequent RSC payload streamed to an already-hydrated client — a distinction that matters if you're trying to correlate error rates with, say, a specific navigation pattern rather than initial page loads.
Because error is typed as unknown rather than Error, don't assume its shape — narrow it explicitly (as the example above does with instanceof Error and a manual property check) before reading message or digest off of it. Treating it as a guaranteed Error instance is a common source of runtime crashes inside error-handling code itself, which is about as unhelpful a failure mode as you can create.
Targeting a Specific Runtime
instrumentation.js runs in both the Node.js and Edge runtimes by default, which is convenient until your setup logic genuinely needs to differ between the two — a tracing SDK with a Node-specific transport, for instance, that simply can't run on Edge. process.env.NEXT_RUNTIME is how you branch:
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");
}
}
This pattern — a thin dispatcher in instrumentation.js that requires an environment-specific implementation file — keeps your actual setup logic out of a giant conditional and lets each runtime-specific file import only what that runtime can actually support, which matters because some Node-only packages will fail to even load under the Edge runtime, not just fail to behave correctly.
How This Differs From instrumentation-client.js
It's worth being explicit about a distinction the filename similarity invites confusion over: this file (instrumentation.js) runs server-side only — inside your Next.js server process, whether that's Node.js or Edge. There's a separate, newer convention, instrumentation-client.js, that runs in the browser, before hydration, for client-side monitoring like navigation tracking and frontend performance marks. They solve adjacent but distinct problems, and neither is a substitute for the other — a production observability setup typically wants both, not one or the other.
Version History
| Version | Changes |
|---|---|
v15.0.0 | onRequestError introduced; instrumentation became stable |
v14.0.4 | Turbopack support for instrumentation |
v13.2.0 | instrumentation introduced as an experimental feature |
Key Takeaways
| Export | Guarantee |
|---|---|
register() | Runs exactly once per server instance, must complete before requests are served, can be async |
onRequestError() | Fires on every captured server-side error; always await internal async work |
error param | Typed unknown; may not be the original thrown error — use digest to correlate with server logs |
context param | Tells you which router, which route, and which rendering phase the error came from |
| Runtime targeting | Use process.env.NEXT_RUNTIME to branch Node.js vs. Edge-specific setup code |
| Scope | Server-side only — pair with instrumentation-client.js for browser-side observability |
For a two-function file, instrumentation.js carries a lot of responsibility: it's the difference between observability tooling that's reliably wired up from the very first request, versus tooling initialized somewhere in application code where cold-start behavior is left to chance. Get register and onRequestError right once, and the rest of your monitoring stack builds on a foundation that actually holds up under real production traffic.


