
Next.js Instrumentation with OpenTelemetry
"Add some logging" is usually the first response to "we can't tell what's slow in production," and it's usually the wrong one past a certain scale. Logs tell you that something happened; they're bad at telling you how one request's work relates to another's, or which specific step in a chain of twelve nested operations actually ate the 800ms your users are complaining about. That's the problem distributed tracing solves, and OpenTelemetry is the vendor-neutral standard Next.js has built its own internal instrumentation around.
This article covers what OpenTelemetry actually gives you, how to wire it into a Next.js app with the least friction possible, and what the framework instruments automatically before you write a single line of your own tracing code.
Why OpenTelemetry specifically
The core pitch for OpenTelemetry over a vendor-specific SDK is portability: you instrument your code once, against OpenTelemetry's API, and the actual destination for that telemetry data — Datadog, Honeycomb, Jaeger, an internal collector — is a separate configuration decision, swappable without touching your instrumentation code. Given how often companies migrate observability vendors as pricing or feature needs change, that decoupling is worth more than it sounds on paper.
Next.js leans into this directly: the framework already instruments itself with OpenTelemetry. Route rendering, fetch calls, API route execution, getServerSideProps/getStaticProps in the Pages Router — all of it emits spans out of the box, before you add a single custom span of your own. Setting this up is mostly about telling Next.js where to send that data, not building the instrumentation from scratch.
If terms like Span, Trace, and Exporter are unfamiliar, they're standard OpenTelemetry vocabulary worth a quick detour through the Observability Primer before diving in — this article assumes a working familiarity with them rather than re-explaining the whole model from scratch.
The fast path: @vercel/otel
Configuring OpenTelemetry manually is genuinely verbose — enough that Vercel maintains a wrapper package, @vercel/otel, specifically to collapse that setup into a few lines for the common case.
npm install @vercel/otel @opentelemetry/sdk-logs @opentelemetry/api-logs @opentelemetry/instrumentation
Then create an instrumentation.ts (or .js) file in your project root — not inside app/ or pages/, and if you're using a src/ layout, it goes inside src/ alongside those directories, not above them:
// instrumentation.ts
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({ serviceName: "next-app" });
}
Two small gotchas worth flagging before they cost you a confused half hour: if you've customized pageExtensions in your config to use a different suffix convention, the instrumentation filename itself needs to match that suffix — it's easy to forget this file follows the same naming rule as your pages. And the file's location matters exactly as much as the docs say it does; instrumentation.ts sitting inside app/ instead of the project root simply won't be picked up, silently.
That's genuinely the entire setup for the common case. @vercel/otel handles the SDK initialization, the exporter wiring, and the semantic-convention boilerplate you'd otherwise write by hand.
When you need manual configuration
@vercel/otel covers most needs, but it has one real limitation: it wraps NodeSDK, which isn't compatible with the Edge runtime. If you need OpenTelemetry running across both Node.js and Edge routes in the same app, or you need configuration options @vercel/otel doesn't expose, manual setup is the fallback.
npm install @opentelemetry/sdk-node @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/sdk-trace-node @opentelemetry/exporter-trace-otlp-http
Because NodeSDK can't run on Edge, the pattern is to gate the Node-specific import behind an explicit runtime check, importing a separate file only when running under Node.js:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation.node.ts");
}
}
// instrumentation.node.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: "next-app",
}),
spanProcessor: new SimpleSpanProcessor(new OTLPTraceExporter()),
});
sdk.start();
Functionally, this produces equivalent behavior to @vercel/otel for the Node.js side — the manual route just gives you direct access to configuration surfaces the wrapper package doesn't expose, at the cost of writing (and maintaining) that boilerplate yourself. If Edge runtime support genuinely matters to your setup, @vercel/otel remains the only path that covers it; the manual NodeSDK approach simply can't run there.
Verifying it's actually working
You need something to actually receive and display the traces before you can confirm any of this is working — an OpenTelemetry Collector paired with a compatible backend. Vercel publishes a ready-to-run dev environment for exactly this, which is the fastest way to see real traces locally without standing up your own collector infrastructure first.
Once it's running, a successful request should show up as a root span labeled GET /requested/pathname, with every other span from that request nested underneath it in the trace tree. If you're seeing fewer spans than you'd expect, that's likely intentional — Next.js traces considerably more internally than it emits by default. Set NEXT_OTEL_VERBOSE=1 to see the fuller picture.
Deploying this to production
On Vercel, OpenTelemetry is wired up to work out of the box — connecting your project to an observability provider is a dashboard-level integration step, not additional code.
Self-hosting means you own the collector. The general shape: spin up your own OpenTelemetry Collector following its own getting-started guide, configure it to receive data from your Next.js app, and then deploy the app normally to whatever platform you're using. None of this is Next.js-specific configuration at that point — it's standard OpenTelemetry Collector operations.
If you'd rather skip the collector entirely, that's also a supported path: a custom exporter, configured through either @vercel/otel or the manual NodeSDK setup above, can send telemetry data directly to your backend of choice without an intermediate collector process at all.
Adding your own spans
Everything so far covers what Next.js instruments automatically. For anything specific to your own business logic — a particularly slow external API call, a data transformation worth tracking independently — you add custom spans directly with the OpenTelemetry API:
npm install @opentelemetry/api
import { trace } from "@opentelemetry/api";
export async function fetchGithubStars() {
return await trace
.getTracer("nextjs-example")
.startActiveSpan("fetchGithubStars", async (span) => {
try {
return await getValue();
} finally {
span.end();
}
});
}
The try/finally here isn't decorative — span.end() needs to run whether getValue() succeeds or throws, or you'll end up with spans that never close, quietly corrupting your trace data over time. This is a pattern worth internalizing rather than copy-pasting once and forgetting: every span you open manually needs a guaranteed corresponding end() call, regardless of the code path it exits through.
Your register() function runs before any of your application code in a fresh environment, which is exactly why it's the right place to initialize the SDK — everything that runs afterward, including these custom spans, gets correctly attached to the exported trace.
What Next.js instruments for you, automatically
This is the part worth actually reading closely, because it tells you what you're getting for free versus what you'd need to add yourself. Every automatic span follows OpenTelemetry's semantic conventions, plus a handful of custom attributes under a next namespace: next.span_name, next.span_type (a unique identifier per span kind), next.route (the matched route pattern, e.g. /[param]/user), next.rsc (whether the request was an RSC request, such as a prefetch), and next.page — an internal identifier for which special file (page.ts, layout.ts, loading.ts, etc.) is involved, useful mainly when paired with next.route since next.page alone can't distinguish /(groupA)/layout.ts from /(groupB)/layout.ts.
The span roster itself:
| Span | next.span_type | What it covers |
|---|---|---|
[http.method] [next.route] | BaseServer.handleRequest | Root span for every incoming request — method, route, status code |
render route (app) [next.route] | AppRender.getBodyResult | Rendering a route in the App Router |
fetch [http.method] [http.url] | AppRender.fetch | Every fetch() call your code executes |
executing api route (app) [next.route] | AppRouteRouteHandlers.runHandler | A Route Handler's execution |
getServerSideProps [next.route] | Render.getServerSideProps | Pages Router SSR data fetching |
getStaticProps [next.route] | Render.getStaticProps | Pages Router static data fetching |
render route (pages) [next.route] | Render.renderDocument | Rendering a Pages Router document |
generateMetadata [next.page] | ResolveMetadata.generateMetadata | Metadata generation (can fire multiple times per route) |
resolve page components | NextNodeServer.findPageComponents | Locating a page's components |
resolve segment modules | NextNodeServer.getLayoutOrPageModule | Loading a layout or page's code modules |
start response | NextNodeServer.startResponse | Zero-length marker for time-to-first-byte |
A couple of these are worth calling out specifically because of what they enable in practice. The fetch span means every outbound request your app makes — third-party APIs, your own backend, anything — shows up nested under the request that triggered it, which is usually exactly the granularity you want when hunting for "why is this route slow" (was it the render, or was it a slow upstream API?). If you'd rather instrument fetch calls with your own custom logic instead of relying on this built-in span, set NEXT_OTEL_FETCH_DISABLED=1 to turn it off cleanly rather than having two overlapping fetch instrumentations fighting over the same calls.
The zero-length start response span is a subtler but genuinely useful one: it marks the exact moment the first byte went out, which is precisely the metric you want when diagnosing time-to-first-byte issues separately from total request duration — a route can have a fast TTFB and a slow total time (streaming a lot of content), or a slow TTFB and fast total time (blocked upstream, then fast once it starts), and this span is what lets you tell those two failure modes apart in a trace rather than just seeing one aggregate duration.
Key Takeaways
| Question | Answer |
|---|---|
| Fastest setup path | @vercel/otel, a few lines in instrumentation.ts |
| When to go manual | Edge + Node.js runtime coverage in one app, or config options @vercel/otel doesn't expose |
| Does Next.js instrument itself? | Yes — routing, rendering, fetch calls, and more, before you write anything |
| See more spans | Set NEXT_OTEL_VERBOSE=1 |
| Turn off automatic fetch tracing | NEXT_OTEL_FETCH_DISABLED=1 |
| Custom spans | @opentelemetry/api, always paired with span.end() in a finally block |
| Local testing | Vercel's OpenTelemetry dev environment (collector + backend, pre-wired) |
The honest pitch for doing this at all is that Next.js has already done the hard, tedious part — instrumenting its own internals — for you. Wiring up @vercel/otel and pointing it at a collector is a genuinely small amount of work for what you get back: a trace tree that shows exactly which render, which fetch, and which route segment actually accounted for the latency your users experienced, instead of a pile of timestamped log lines you have to manually stitch back together after the fact.


