Observability in Next.js: OpenTelemetry and ClickStack step by step

MJ

Mateusz JanotaCEO & Founder

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.

yaml
services:
  clickstack:
    image: docker.hyperdx.io/hyperdx/hyperdx-local:latest
    ports:
      - "8080:8080"
      - "4318:4318"
    environment:
      HYPERDX_API_KEY: local-dev-key

Run it:

bash
docker compose up -d clickstack
curl -f http://localhost:8080 || true

The 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.

bash
pnpm add @vercel/otel @opentelemetry/api

Then enable instrumentation in next.config.ts if your Next.js version requires it:

ts
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.

ts
import { registerOTel } from "@vercel/otel";

export function register() {
  registerOTel({
    serviceName: "marketing",
  });
}

For local testing, export the endpoint before starting the dev server:

bash
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_SERVICE_NAME=marketing-local
pnpm dev

Add 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.

ts
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:

ts
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.

ts
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.

bash
docker compose logs -f clickstack
curl -I http://localhost:8080

You 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.

Have a project in mind?

Message us

Let's talk about how we can help bring your ideas to life.

Zanek

Can't keep up with changes in AI world?

Let us do the heavy lifting. Every week we distill the most important AI developments into a focused 5-minute briefing - so you stay ahead without the noise.

Find out more
Weekly AIonline