Skip to content

Next.js

Add observability to your Next.js App Router application and see HTTP routes, server actions, and (optionally) browser page loads in Kopai. This guide follows the official Next.js OpenTelemetry guide and offers two paths:

  • @vercel/otel — the recommended default. Four lines of code. Covers server-side HTTP spans.
  • Manual OpenTelemetry SDK — when you need browser traces, end-to-end distributed tracing from client to server, or control over processors, exporters, and sampling.

Both approaches read the OTLP endpoint from the standard OTEL_EXPORTER_OTLP_ENDPOINT environment variable.

  • Node.js 22+
  • A Next.js 15 App Router project (npx create-next-app@latest)
  • Kopai running locally:
Terminal window
npx @kopai/app start

Install the package:

Terminal window
pnpm add @vercel/otel

Create src/instrumentation.ts at the root of your src/ directory:

import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({ serviceName: "my-nextjs-app" });
}

That’s it. Next.js automatically detects the register() hook and initializes OTel before serving the first request. You’ll get HTTP spans for every route and API handler.

Set the OTLP endpoint and start the dev server:

Terminal window
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
pnpm dev

Use this when you need:

  • Browser spans (document load, fetch calls)
  • End-to-end distributed tracing — browser spans as parents of the server spans they trigger
  • Control over span processors, exporters, or sampling

Install the Node SDK:

Terminal window
pnpm add @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources @opentelemetry/sdk-trace-node @opentelemetry/semantic-conventions

Create src/instrumentation.ts with a runtime guard so the Node SDK is never imported into the Edge runtime:

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

Create src/instrumentation.node.ts:

import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: "my-nextjs-app-server",
}),
spanProcessor: new SimpleSpanProcessor(new OTLPTraceExporter()),
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().catch(() => process.exit(0));
});

Install the browser packages:

Terminal window
pnpm add @opentelemetry/sdk-trace-web @opentelemetry/sdk-trace-base \
@opentelemetry/context-zone @opentelemetry/instrumentation \
@opentelemetry/instrumentation-document-load @opentelemetry/instrumentation-fetch

The browser can’t POST directly to http://localhost:4318 due to CORS. Add a same-origin proxy at src/app/api/otel/route.ts:

import { NextResponse } from "next/server";
export async function POST(request: Request) {
const endpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
const body = await request.arrayBuffer();
const res = await fetch(`${endpoint}/v1/traces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return new NextResponse(null, { status: res.status });
}

Initialize the browser tracer in a client component at src/app/otel-provider.tsx:

"use client";
import { useEffect, useRef } from "react";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { DocumentLoadInstrumentation } from "@opentelemetry/instrumentation-document-load";
export default function OtelProvider({
children,
}: {
children: React.ReactNode;
}) {
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
const provider = new WebTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: "my-nextjs-app-browser",
}),
spanProcessors: [
new BatchSpanProcessor(new OTLPTraceExporter({ url: "/api/otel" })),
],
});
provider.register({ contextManager: new ZoneContextManager() });
registerInstrumentations({
instrumentations: [
new DocumentLoadInstrumentation(),
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [/.*/],
ignoreUrls: [/\/api\/otel/],
}),
],
});
}, []);
return <>{children}</>;
}

Wrap your root layout with it in src/app/layout.tsx:

import OtelProvider from "./otel-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<OtelProvider>{children}</OtelProvider>
</body>
</html>
);
}

FetchInstrumentation injects traceparent headers into every outgoing fetch call, so the browser spans become parents of the server spans your API routes create — giving you a single trace spanning client and server.

Start the app and generate traffic:

Terminal window
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
pnpm dev

Open http://localhost:3000 and click around. Then query the Kopai CLI:

Terminal window
# List recent traces
npx @kopai/cli traces search --json
# Inspect a specific trace (copy a traceId from above)
npx @kopai/cli traces get <trace-id> --json

With the manual SDK setup, a single trace should contain both my-nextjs-app-browser and my-nextjs-app-server spans in a parent-child relationship.

Change the environment variables to point at the cloud endpoint with your API key:

Terminal window
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-http.kopai.app"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_BACKEND_TOKEN"

No code changes needed — both approaches read these variables automatically. For the manual SDK setup, the /api/otel proxy route forwards requests from the browser to whatever endpoint it reads at runtime.

Two complete runnable examples, one per approach: