Complete Guide to Setting Up OpenTelemetry with Next.js 15 for Production Observability

Introduction
In the world of modern applications, it is not enough for your app to work — you need to know how it works. Are responses slow? Where does the server spend most of its time? What are the bottlenecks? This is where OpenTelemetry (OTel) comes in — the open standard for observability and distributed tracing.
In this comprehensive guide, you will learn how to:
- Set up OpenTelemetry with Next.js 15 from scratch
- Automatically trace API requests and pages
- Add custom spans to measure critical operation performance
- Export data to Jaeger for tracing and Prometheus for metrics
- Visualize everything in an integrated Grafana dashboard
- Deploy the setup in production with Docker Compose
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed on your machine
- Docker and Docker Compose for local services
- Basic knowledge of Next.js and TypeScript
- Familiarity with HTTP and REST API concepts
- A code editor (VS Code recommended)
What You Will Build
You will build a Next.js 15 application with a complete observability stack including:
- Automatic tracing for all HTTP requests and pages
- Custom spans for database operations and external services
- Performance metrics like response time and error rate
- Monitoring dashboard integrated in Grafana
- Alerts when performance thresholds are exceeded
Step 1: Create a Next.js 15 Project
Start by creating a new Next.js project:
npx create-next-app@latest otel-nextjs-demo --typescript --tailwind --app --src-dir
cd otel-nextjs-demoChoose the default options when prompted. After installation, verify the project runs:
npm run devStep 2: Install OpenTelemetry Packages
Install the required OpenTelemetry packages:
npm install @opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-metrics \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/instrumentation-http \
@opentelemetry/instrumentation-fetch \
@vercel/otelThe @vercel/otel package provides optimized integration with Next.js and greatly simplifies the initial setup. You can use it with any OpenTelemetry provider, not just Vercel.
Step 3: Configure OpenTelemetry in Next.js
Enable the Experimental Feature
First, enable OpenTelemetry support in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
instrumentationHook: true,
},
};
export default nextConfig;Create the Instrumentation File
Create src/instrumentation.ts — this is the entry point for OpenTelemetry:
// src/instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { NodeSDK } = await import("@opentelemetry/sdk-node");
const { OTLPTraceExporter } = await import(
"@opentelemetry/exporter-trace-otlp-http"
);
const { OTLPMetricExporter } = await import(
"@opentelemetry/exporter-metrics-otlp-http"
);
const { PeriodicExportingMetricReader } = await import(
"@opentelemetry/sdk-metrics"
);
const { Resource } = await import("@opentelemetry/resources");
const {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} = await import("@opentelemetry/semantic-conventions");
const { HttpInstrumentation } = await import(
"@opentelemetry/instrumentation-http"
);
const resource = new Resource({
[ATTR_SERVICE_NAME]: "nextjs-otel-demo",
[ATTR_SERVICE_VERSION]: "1.0.0",
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]:
process.env.NODE_ENV || "development",
});
const traceExporter = new OTLPTraceExporter({
url:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
"http://localhost:4318/v1/traces",
});
const metricExporter = new OTLPMetricExporter({
url:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
"http://localhost:4318/v1/metrics",
});
const sdk = new NodeSDK({
resource,
traceExporter,
metricReader: new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 10000,
}),
instrumentations: [new HttpInstrumentation()],
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().then(
() => console.log("OpenTelemetry SDK shut down successfully"),
(err) => console.error("Error shutting down OpenTelemetry SDK", err)
);
});
}
}The process.env.NEXT_RUNTIME === "nodejs" check is essential because the OpenTelemetry SDK only works in the Node.js runtime and does not support Edge Runtime. Without this check, your app will fail when using Middleware or Edge Functions.
Step 4: Set Up Monitoring Services with Docker Compose
Create a docker-compose.yml file at the project root to run Jaeger, Prometheus, Grafana, and the OpenTelemetry Collector:
# docker-compose.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yml"]
volumes:
- ./otel-collector-config.yml:/etc/otel-collector-config.yml
ports:
- "4317:4317" # gRPC
- "4318:4318" # HTTP
- "8889:8889" # Prometheus metrics
depends_on:
- jaeger
jaeger:
image: jaegertracing/all-in-one:latest
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686" # Jaeger UI
- "14268:14268" # Jaeger collector
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin123
volumes:
- grafana-data:/var/lib/grafana
ports:
- "3001:3000"
depends_on:
- prometheus
- jaeger
volumes:
grafana-data:Configure the OpenTelemetry Collector
Create otel-collector-config.yml:
# otel-collector-config.yml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
prometheus:
endpoint: "0.0.0.0:8889"
namespace: "nextjs"
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]Configure Prometheus
Create prometheus.yml:
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "otel-collector"
static_configs:
- targets: ["otel-collector:8889"]
- job_name: "nextjs-app"
static_configs:
- targets: ["host.docker.internal:3000"]Start all services:
docker compose up -dStep 5: Add Custom Spans
Create a Tracing Helper
Create src/lib/tracing.ts for easy-to-use tracing utilities:
// src/lib/tracing.ts
import { trace, SpanStatusCode, Span, context } from "@opentelemetry/api";
const tracer = trace.getTracer("nextjs-app", "1.0.0");
export function withSpan<T>(
name: string,
fn: (span: Span) => Promise<T>,
attributes?: Record<string, string | number | boolean>
): Promise<T> {
return tracer.startActiveSpan(name, async (span) => {
try {
if (attributes) {
Object.entries(attributes).forEach(([key, value]) => {
span.setAttribute(key, value);
});
}
const result = await fn(span);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : "Unknown error",
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
export function createDbSpan<T>(
operation: string,
table: string,
fn: (span: Span) => Promise<T>
): Promise<T> {
return withSpan(`db.${operation}`, fn, {
"db.system": "postgresql",
"db.operation": operation,
"db.sql.table": table,
});
}
export function createExternalApiSpan<T>(
service: string,
endpoint: string,
fn: (span: Span) => Promise<T>
): Promise<T> {
return withSpan(`external.${service}`, fn, {
"http.url": endpoint,
"peer.service": service,
});
}
export { tracer };Use Spans in API Routes
Create an API route that uses custom tracing:
// src/app/api/users/route.ts
import { NextResponse } from "next/server";
import { withSpan, createDbSpan } from "@/lib/tracing";
// Simulated database
async function fetchUsersFromDb() {
return createDbSpan("SELECT", "users", async (span) => {
// Simulate database latency
await new Promise((resolve) => setTimeout(resolve, 50));
span.setAttribute("db.rows_affected", 10);
return [
{ id: 1, name: "Ahmed", email: "ahmed@example.com" },
{ id: 2, name: "Sara", email: "sara@example.com" },
{ id: 3, name: "Mohamed", email: "mohamed@example.com" },
];
});
}
async function enrichUserData(users: any[]) {
return withSpan(
"enrichUserData",
async (span) => {
span.setAttribute("users.count", users.length);
// Simulate enrichment from external service
await new Promise((resolve) => setTimeout(resolve, 30));
return users.map((user) => ({
...user,
avatar: `https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`,
}));
},
{ "enrichment.source": "dicebear" }
);
}
export async function GET() {
return withSpan("GET /api/users", async (span) => {
span.setAttribute("http.method", "GET");
span.setAttribute("http.route", "/api/users");
const users = await fetchUsersFromDb();
const enrichedUsers = await enrichUserData(users);
span.setAttribute("response.count", enrichedUsers.length);
return NextResponse.json({
users: enrichedUsers,
total: enrichedUsers.length,
});
});
}Step 6: Add Custom Metrics
Create src/lib/metrics.ts to define metrics:
// src/lib/metrics.ts
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("nextjs-app", "1.0.0");
// Request counter
export const requestCounter = meter.createCounter("http_requests_total", {
description: "Total number of HTTP requests",
unit: "requests",
});
// Response time histogram
export const responseTimeHistogram = meter.createHistogram(
"http_response_duration_ms",
{
description: "HTTP response duration in milliseconds",
unit: "ms",
}
);
// Error counter
export const errorCounter = meter.createCounter("http_errors_total", {
description: "Total number of HTTP errors",
unit: "errors",
});
// Active connections gauge
export const activeConnections = meter.createUpDownCounter(
"active_connections",
{
description: "Number of currently active connections",
unit: "connections",
}
);Integrate Metrics in Middleware
Create middleware to automatically record metrics:
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const start = Date.now();
const response = NextResponse.next();
// Add tracing headers
response.headers.set("X-Request-Start", start.toString());
response.headers.set(
"X-Request-Id",
crypto.randomUUID()
);
return response;
}
export const config = {
matcher: ["/api/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
};Create an API Route with Full Metrics
// src/app/api/health/route.ts
import { NextResponse } from "next/server";
import {
requestCounter,
responseTimeHistogram,
errorCounter,
} from "@/lib/metrics";
import { withSpan } from "@/lib/tracing";
export async function GET() {
const start = Date.now();
return withSpan("GET /api/health", async (span) => {
try {
// Record request
requestCounter.add(1, {
method: "GET",
route: "/api/health",
});
// Simulate health checks
const checks = {
database: "healthy",
cache: "healthy",
external_api: "healthy",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
};
// Record response time
const duration = Date.now() - start;
responseTimeHistogram.record(duration, {
method: "GET",
route: "/api/health",
status: "200",
});
span.setAttribute("health.status", "healthy");
span.setAttribute("health.duration_ms", duration);
return NextResponse.json(checks);
} catch (error) {
errorCounter.add(1, {
method: "GET",
route: "/api/health",
error_type: "health_check_failed",
});
throw error;
}
});
}Step 7: Build a Monitoring Dashboard Page
Create a simple page to display monitoring status:
// src/app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";
interface HealthStatus {
database: string;
cache: string;
external_api: string;
uptime: number;
timestamp: string;
}
export default function DashboardPage() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchHealth = async () => {
try {
const res = await fetch("/api/health");
const data = await res.json();
setHealth(data);
} catch (err) {
console.error("Failed to fetch health:", err);
} finally {
setLoading(false);
}
};
fetchHealth();
const interval = setInterval(fetchHealth, 5000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">
Monitoring Dashboard
</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{health && (
<>
<StatusCard title="Database" status={health.database} />
<StatusCard title="Cache" status={health.cache} />
<StatusCard title="External API" status={health.external_api} />
</>
)}
</div>
<div className="bg-gray-900 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Quick Links</h2>
<div className="space-y-3">
<ExternalLink
href="http://localhost:16686"
label="Jaeger UI — View Traces"
/>
<ExternalLink
href="http://localhost:9090"
label="Prometheus — Query Metrics"
/>
<ExternalLink
href="http://localhost:3001"
label="Grafana — Dashboards"
/>
</div>
</div>
</div>
);
}
function StatusCard({
title,
status,
}: {
title: string;
status: string;
}) {
const isHealthy = status === "healthy";
return (
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-sm font-medium text-gray-400 mb-2">{title}</h3>
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isHealthy ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className={isHealthy ? "text-green-400" : "text-red-400"}>
{isHealthy ? "Healthy" : "Unhealthy"}
</span>
</div>
</div>
);
}
function ExternalLink({
href,
label,
}: {
href: string;
label: string;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<span>→</span>
{label}
</a>
);
}Step 8: Set Up Grafana Dashboard
After starting the services with docker compose up -d, open Grafana at http://localhost:3001 and log in with admin / admin123.
Add Data Sources
- Go to Configuration then Data Sources
- Add Prometheus with URL:
http://prometheus:9090 - Add Jaeger with URL:
http://jaeger:16686
Create a Custom Dashboard
Create a grafana-dashboard.json file for importing into Grafana:
{
"dashboard": {
"title": "Next.js Application Monitoring",
"panels": [
{
"title": "Request Rate",
"type": "timeseries",
"targets": [
{
"expr": "rate(nextjs_http_requests_total[5m])",
"legendFormat": "Requests/sec"
}
]
},
{
"title": "Response Time (p95)",
"type": "gauge",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m]))",
"legendFormat": "p95 Latency"
}
]
},
{
"title": "Error Rate",
"type": "stat",
"targets": [
{
"expr": "rate(nextjs_http_errors_total[5m])",
"legendFormat": "Errors/sec"
}
]
}
]
}
}You can import this file through the Grafana UI via Dashboards, then Import, then paste the JSON content. You will get a ready-made dashboard with three panels.
Step 9: Advanced Tracing — Connecting Frontend to Backend
To correlate browser traces with server traces, create a client-side utility:
// src/lib/client-tracing.ts
export function createTracedFetch(
originalFetch: typeof fetch
): typeof fetch {
return async (input, init) => {
const requestId = crypto.randomUUID();
const start = performance.now();
const headers = new Headers(init?.headers);
headers.set("X-Request-Id", requestId);
headers.set("X-Client-Start", start.toString());
try {
const response = await originalFetch(input, {
...init,
headers,
});
const duration = performance.now() - start;
console.debug(
`[Trace] ${
typeof input === "string" ? input : input.url
} - ${duration.toFixed(1)}ms`
);
return response;
} catch (error) {
const duration = performance.now() - start;
console.error(
`[Trace Error] ${
typeof input === "string" ? input : input.url
} - ${duration.toFixed(1)}ms`,
error
);
throw error;
}
};
}Use the Traced Fetch in Components
// src/hooks/useTracedFetch.ts
"use client";
import { useCallback } from "react";
import { createTracedFetch } from "@/lib/client-tracing";
const tracedFetch = createTracedFetch(fetch);
export function useTracedFetch() {
const fetchWithTrace = useCallback(
async <T>(url: string, options?: RequestInit): Promise<T> => {
const response = await tracedFetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
[]
);
return { fetch: fetchWithTrace };
}Step 10: Set Up Alerts
Prometheus Alert Rules
Create alert-rules.yml:
# alert-rules.yml
groups:
- name: nextjs-alerts
rules:
- alert: HighErrorRate
expr: rate(nextjs_http_errors_total[5m]) > 0.1
for: 2m
labels:
severity: critical
annotations:
summary: "High error rate in Next.js application"
description: "Error rate exceeded 10% over the last 5 minutes"
- alert: SlowResponseTime
expr: histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m])) > 2000
for: 5m
labels:
severity: warning
annotations:
summary: "Slow response time detected"
description: "p95 response time exceeded 2000ms"
- alert: HighMemoryUsage
expr: process_resident_memory_bytes > 512 * 1024 * 1024
for: 10m
labels:
severity: warning
annotations:
summary: "High memory usage"
description: "Memory usage exceeded 512MB"Update prometheus.yml to include alert rules:
# prometheus.yml (updated)
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alert-rules.yml"
scrape_configs:
- job_name: "otel-collector"
static_configs:
- targets: ["otel-collector:8889"]Step 11: Configure Environment Variables
Create .env.local for local development:
# .env.local
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=nextjs-otel-demo
OTEL_LOG_LEVEL=info
NODE_ENV=developmentAnd .env.production for production:
# .env.production
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.yourdomain.com
OTEL_SERVICE_NAME=nextjs-otel-production
OTEL_LOG_LEVEL=warn
NODE_ENV=productionStep 12: Test the Complete System
Start the Services
# Start monitoring services
docker compose up -d
# Start the Next.js app
npm run devGenerate Trace Data
Use curl or the browser to send requests:
# Single request
curl http://localhost:3000/api/users
# Health check
curl http://localhost:3000/api/health
# Generate light load (10 requests)
for i in $(seq 1 10); do
curl -s http://localhost:3000/api/users > /dev/null
echo "Request $i sent"
doneVerify the Results
-
Jaeger — Open
http://localhost:16686- Select the
nextjs-otel-demoservice - You will see traces for each request with detailed timing
- Click any trace to see the span tree
- Select the
-
Prometheus — Open
http://localhost:9090- Try the query:
nextjs_http_requests_total - Try:
histogram_quantile(0.95, rate(nextjs_http_response_duration_ms_bucket[5m]))
- Try the query:
-
Grafana — Open
http://localhost:3001- Import the monitoring dashboard from the JSON file
- Watch metrics in real time
Troubleshooting
No Traces Appearing in Jaeger
- Verify the OpenTelemetry Collector is running:
docker compose logs otel-collector - Check that
OTEL_EXPORTER_OTLP_ENDPOINTpoints to the correct address - Make sure
instrumentationHook: trueis enabled innext.config.ts
Metrics Not Showing in Prometheus
- Verify the Collector is exporting to Prometheus:
curl http://localhost:8889/metrics - Check
scrape_configsis properly configured inprometheus.yml
Errors in Edge Runtime Environment
- OpenTelemetry SDK does not support Edge Runtime
- Use the
NEXT_RUNTIME === "nodejs"guard in your instrumentation file - Do not import OTel packages in Middleware or Edge API Routes
High Memory Consumption
- Reduce
send_batch_sizein Collector settings - Increase
exportIntervalMillisto reduce export frequency - Use sampling to reduce data volume in production
Production Best Practices
1. Use Sampling
In production, you do not need to trace every request. Use tail-based sampling:
// In instrumentation.ts
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-node";
const sdk = new NodeSDK({
// Only trace 10% of requests
sampler: new TraceIdRatioBasedSampler(0.1),
// ... rest of config
});2. Avoid Over-Tracing
- Do not add spans for very fast operations (under 1ms)
- Avoid recording sensitive data in attributes
- Use appropriate log levels for each environment
3. Secure Endpoints
# In production, use TLS and Authentication
exporters:
otlp/secure:
endpoint: https://otel.yourdomain.com:4317
tls:
cert_file: /etc/ssl/certs/client.crt
key_file: /etc/ssl/private/client.key
headers:
Authorization: "Bearer YOUR_TOKEN"4. Monitor the OpenTelemetry Collector Itself
# Add Collector health monitoring
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
service:
extensions: [health_check, zpages]Next Steps
After completing this guide, you can expand to:
- Distributed Tracing — Correlate traces across multiple microservices
- Custom Metrics — Add business-specific metrics like active user counts
- Log Correlation — Link logs to traces using trace_id
- Real User Monitoring — Track browser performance with OpenTelemetry Browser SDK
- CI/CD Integration — Add tracing to your deployment pipeline
Conclusion
In this guide, you learned how to:
- Set up OpenTelemetry with Next.js 15 using the instrumentation hook
- Trace requests automatically and add custom spans for specific operations
- Collect metrics like request rate, response time, and error rate
- Deploy a complete observability stack with Jaeger, Prometheus, and Grafana
- Configure alerts to detect issues before users notice them
Good observability is not a luxury — it is a necessity for any production application. With OpenTelemetry, you now have complete visibility into what is happening inside your application.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Deploy a Next.js Application with Docker and CI/CD in Production
Learn how to containerize your Next.js application with Docker, set up a CI/CD pipeline with GitHub Actions, and deploy to production on a VPS. A complete guide from development to automated deployment.

Docker Compose for Full-Stack Developers: Next.js, PostgreSQL, and Redis
Learn how to containerize a full-stack Next.js application with PostgreSQL and Redis using Docker Compose. This hands-on tutorial covers multi-service orchestration, development workflows, hot reloading, health checks, and production-ready configurations.

Next.js 15 Partial Prerendering (PPR): Build a Blazing-Fast Dashboard with Hybrid Rendering
Master Next.js 15 Partial Prerendering (PPR) — combine static and dynamic rendering in a single page. Build an analytics dashboard with instant static shells and streaming dynamic content.