Next.js makes it easy to ship fast interfaces, but once the application grows, console logs are not enough. You need to know which request was slow, which external call failed, how long rendering took, and whether the issue came from your code, the database, or a third-party API.
This guide shows a pragmatic setup: OpenTelemetry inside a Next.js app and ClickStack as the local place where traces become searchable. The goal is not to add observability for its own sake. The goal is to debug production-like problems with evidence instead of guessing.
What you will build
We will use a small but realistic stack:
a Next.js app with instrumentation enabled,
OpenTelemetry spans around server-side work,
a local ClickStack instance,
verification commands that prove traces are being exported,
a few practical patterns you can reuse in production.
The examples assume an App Router project, but the same tracing ideas apply to route handlers, server actions, scheduled jobs, and background workers.
Start ClickStack locally
For local work, keep the observability stack separate from the app. A minimal Docker Compose file is enough to verify the flow before you wire it into production.
services:
clickstack:
image: docker.hyperdx.io/hyperdx/hyperdx-local:latest
ports:
- "8080:8080"
- "4318:4318"
environment:
HYPERDX_API_KEY: local-dev-keyservices:
clickstack:
image: docker.hyperdx.io/hyperdx/hyperdx-local:latest
ports:
- "8080:8080"
- "4318:4318"
environment:
HYPERDX_API_KEY: local-dev-keyRun it:
docker compose up -d clickstack
curl -f http://localhost:8080 || truedocker compose up -d clickstack
curl -f http://localhost:8080 || trueThe important port for the app is 4318: this is the OTLP HTTP endpoint that receives traces.
Install OpenTelemetry packages
In a Next.js app deployed on Vercel, start with @vercel/otel. Add the OpenTelemetry API package when you want custom spans around your own operations.
pnpm add @vercel/otel @opentelemetry/apipnpm add @vercel/otel @opentelemetry/apiThen enable instrumentation in next.config.ts if your Next.js version requires it:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
instrumentationHook: true,
},
};
export default nextConfig;import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
instrumentationHook: true,
},
};
export default nextConfig;Register instrumentation
Create src/instrumentation.ts. This file runs when the server process starts, so keep it small and avoid importing application code with side effects.
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({
serviceName: "marketing",
});
}import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({
serviceName: "marketing",
});
}For local testing, export the endpoint before starting the dev server:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_SERVICE_NAME=marketing-local
pnpm devexport OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_SERVICE_NAME=marketing-local
pnpm devAdd custom spans around expensive work
Automatic instrumentation is useful, but the biggest gains come from naming the business operations you actually care about: fetching a blog post, loading related case studies, generating metadata, or calling an external API.
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("marketing.blog");
export async function withSpan<T>(name: string, fn: () => Promise<T>): Promise<T> {
return tracer.startActiveSpan(name, async (span) => {
try {
return await fn();
} catch (error) {
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("marketing.blog");
export async function withSpan<T>(name: string, fn: () => Promise<T>): Promise<T> {
return tracer.startActiveSpan(name, async (span) => {
try {
return await fn();
} catch (error) {
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}Use it at boundaries, not around every line of code:
export async function getBlogPost(slug: string, locale: "en" | "pl") {
return withSpan("blog.get_post", async () => {
return sanityClient.fetch(blogPostQuery, { slug, locale });
});
}export async function getBlogPost(slug: string, locale: "en" | "pl") {
return withSpan("blog.get_post", async () => {
return sanityClient.fetch(blogPostQuery, { slug, locale });
});
}Trace fetch calls with useful attributes
When a request is slow, you need enough context to understand what happened without exposing private data.
export async function tracedFetch(input: RequestInfo | URL, init?: RequestInit) {
return withSpan("http.fetch", async () => {
const response = await fetch(input, init);
return response;
});
}export async function tracedFetch(input: RequestInfo | URL, init?: RequestInit) {
return withSpan("http.fetch", async () => {
const response = await fetch(input, init);
return response;
});
}Add attributes such as route name, upstream service, status code, cache mode, or feature flag state. Avoid raw emails, tokens, request bodies, and anything that could become personal data.
Verify that traces arrive
Open a page that calls the instrumented code, then check ClickStack. If the UI is not enough, inspect the collector endpoint and container logs.
docker compose logs -f clickstack
curl -I http://localhost:8080docker compose logs -f clickstack
curl -I http://localhost:8080You should see spans with names like blog.get_post or http.fetch. If nothing appears, check the endpoint, protocol, environment variables, and whether the code path runs on the server rather than in the browser.
What to trace first
Start with operations that answer practical questions:
Which page or route was slow?
Which external dependency caused the delay?
Did the cache work or did the app fetch fresh data?
Did the failure happen before rendering, during metadata generation, or inside a route handler?
A small number of well-named spans beats hundreds of noisy spans that nobody can interpret.
Production checklist
Before using this in production, make the setup explicit:
use a stable
serviceName,set environment variables per deployment target,
sample traces if traffic is high,
never attach secrets or personal data as span attributes,
document the naming convention for spans,
add alerts only after you understand the normal baseline.
Observability is useful when it shortens debugging. Keep the setup boring, name spans after real business operations, and verify the pipeline before you need it during an incident.