Skip to content
Last9 named a Gartner Cool Vendor in AI for SRE Observability for 2025! Read more →
Last9

Next.js

Learn how to integrate OpenTelemetry with Next.js applications and send telemetry data to Last9

This guide shows you how to instrument your Next.js application with OpenTelemetry and send traces, metrics, and logs to Last9. This covers both App Router (13+) and Pages Router approaches.

Prerequisites

  • Node.js 16 or later
  • Next.js 12+ application
  • Last9 account with OTLP endpoint configured

Installation

  1. Install OpenTelemetry packages

    npm install --save @opentelemetry/api \
    @opentelemetry/sdk-node \
    @opentelemetry/auto-instrumentations-node \
    @opentelemetry/exporter-trace-otlp-http \
    @opentelemetry/exporter-metrics-otlp-http \
    @opentelemetry/exporter-logs-otlp-http \
    @opentelemetry/resources \
    @opentelemetry/semantic-conventions
  2. Install Next.js specific packages (optional for enhanced features)

    npm install --save @vercel/otel \
    @opentelemetry/instrumentation-http \
    @opentelemetry/instrumentation-fetch \
    @opentelemetry/instrumentation-fs

Configuration

Set up your environment variables:

export OTEL_EXPORTER_OTLP_ENDPOINT="$last9_otlp_endpoint"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=$last9_otlp_auth_header"
export OTEL_SERVICE_NAME="nextjs-app"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.0.0"
export NODE_ENV="production"

Server-Side Instrumentation

Enable Next.js Instrumentation Hook

/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
// Optional: Enable OpenTelemetry support
experimental: {
serverComponentsExternalPackages: ["@opentelemetry/api"],
},
};
module.exports = nextConfig;

Core Instrumentation Setup

export async function register() {
// Only register instrumentation in Node.js environment (server-side)
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation.node");
}
}

Enhanced Instrumentation with Custom Telemetry

import { trace, metrics, context } from "@opentelemetry/api";
import type { Span, Tracer, Meter } from "@opentelemetry/api";
// Get tracer and meter instances
export const tracer: Tracer = trace.getTracer("nextjs-app", "1.0.0");
export const meter: Meter = metrics.getMeter("nextjs-app", "1.0.0");
// Create custom metrics
export const nextjsMetrics = {
pageViews: meter.createCounter("nextjs_page_views_total", {
description: "Total number of page views",
}),
apiCalls: meter.createCounter("nextjs_api_calls_total", {
description: "Total number of API calls",
}),
renderDuration: meter.createHistogram("nextjs_render_duration_ms", {
description: "Duration of page renders in milliseconds",
unit: "ms",
}),
hydrationDuration: meter.createHistogram("nextjs_hydration_duration_ms", {
description: "Duration of client-side hydration in milliseconds",
unit: "ms",
}),
errorCount: meter.createCounter("nextjs_errors_total", {
description: "Total number of errors",
}),
cacheHits: meter.createCounter("nextjs_cache_hits_total", {
description: "Total number of cache hits",
}),
cacheMisses: meter.createCounter("nextjs_cache_misses_total", {
description: "Total number of cache misses",
}),
};
// Utility functions for common tracing patterns
export const withTracing = async <T>(
name: string,
fn: (span: Span) => Promise<T>,
attributes: Record<string, string | number | boolean> = {},
): Promise<T> => {
return tracer.startActiveSpan(name, async (span) => {
try {
// Set attributes
Object.entries(attributes).forEach(([key, value]) => {
span.setAttributes({ [key]: value });
});
const result = await fn(span);
span.setStatus({ code: 1 }); // OK
return result;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: 2, message: (error as Error).message }); // ERROR
throw error;
} finally {
span.end();
}
});
};
export const getCurrentSpan = (): Span | undefined => {
return trace.getActiveSpan();
};
export const addSpanAttributes = (
attributes: Record<string, string | number | boolean>,
): void => {
const span = getCurrentSpan();
if (span) {
span.setAttributes(attributes);
}
};

App Router (Next.js 13+) Integration

Server Components

import { tracer, nextjsMetrics, withTracing } from '@/lib/telemetry'
import { Suspense } from 'react'
async function fetchUsers() {
return withTracing('users.fetch_all', async (span) => {
const startTime = Date.now()
try {
// Simulate API call
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const users = await response.json()
span.setAttributes({
'users.count': users.length,
'http.status_code': response.status,
'cache.hit': response.headers.get('x-cache') === 'HIT',
})
// Record cache metrics
if (response.headers.get('x-cache') === 'HIT') {
nextjsMetrics.cacheHits.add(1, { type: 'users' })
} else {
nextjsMetrics.cacheMisses.add(1, { type: 'users' })
}
return users
} catch (error) {
nextjsMetrics.errorCount.add(1, {
error_type: 'fetch_error',
operation: 'fetch_users',
})
throw error
} finally {
const duration = Date.now() - startTime
nextjsMetrics.renderDuration.record(duration, {
component: 'users_page',
operation: 'fetch_users',
})
}
})
}
async function UsersList() {
const users = await fetchUsers()
return (
<div className="grid gap-4">
{users.map((user: any) => (
<div key={user.id} className="p-4 border rounded">
<h3 className="font-bold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm">{user.company.name}</p>
</div>
))}
</div>
)
}
export default async function UsersPage() {
// Record page view
nextjsMetrics.pageViews.add(1, {
page: '/users',
type: 'server_component',
})
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Users</h1>
<Suspense fallback={<div>Loading users...</div>}>
<UsersList />
</Suspense>
</div>
)
}
// Generate metadata with tracing
export async function generateMetadata() {
return withTracing('users.generate_metadata', async (span) => {
span.setAttributes({
'metadata.page': '/users',
'metadata.type': 'dynamic',
})
return {
title: 'Users - Next.js App',
description: 'List of all users',
}
})
}

API Routes

import { NextRequest, NextResponse } from "next/server";
import { tracer, nextjsMetrics, withTracing } from "@/lib/telemetry";
export async function GET(request: NextRequest) {
return withTracing("api.users.get", async (span) => {
const startTime = Date.now();
try {
// Parse query parameters
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit") || "10";
const page = searchParams.get("page") || "1";
span.setAttributes({
"api.operation": "list_users",
"api.limit": parseInt(limit),
"api.page": parseInt(page),
});
// Simulate database query
await new Promise((resolve) => setTimeout(resolve, 100));
const users = Array.from({ length: parseInt(limit) }, (_, i) => ({
id: i + 1 + (parseInt(page) - 1) * parseInt(limit),
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
}));
span.setAttributes({
"users.count": users.length,
"response.size": JSON.stringify(users).length,
});
// Record metrics
const duration = Date.now() - startTime;
nextjsMetrics.apiCalls.add(1, {
method: "GET",
route: "/api/users",
status_code: "200",
});
nextjsMetrics.renderDuration.record(duration, {
component: "api_users",
operation: "list",
});
return NextResponse.json({
users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: users.length,
},
});
} catch (error) {
span.recordException(error as Error);
nextjsMetrics.errorCount.add(1, {
error_type: "api_error",
operation: "list_users",
});
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 },
);
}
});
}
export async function POST(request: NextRequest) {
return withTracing("api.users.create", async (span) => {
const startTime = Date.now();
try {
const body = await request.json();
span.setAttributes({
"api.operation": "create_user",
"user.email": body.email,
"request.body_size": JSON.stringify(body).length,
});
// Validate required fields
if (!body.name || !body.email) {
span.setAttributes({
"validation.failed": true,
"validation.missing_fields": [
!body.name && "name",
!body.email && "email",
]
.filter(Boolean)
.join(","),
});
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 },
);
}
// Simulate user creation
await new Promise((resolve) => setTimeout(resolve, 200));
const newUser = {
id: Date.now(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
};
span.setAttributes({
"user.created": true,
"user.id": newUser.id,
});
// Record metrics
const duration = Date.now() - startTime;
nextjsMetrics.apiCalls.add(1, {
method: "POST",
route: "/api/users",
status_code: "201",
});
nextjsMetrics.renderDuration.record(duration, {
component: "api_users",
operation: "create",
});
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
span.recordException(error as Error);
nextjsMetrics.errorCount.add(1, {
error_type: "api_error",
operation: "create_user",
});
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 },
);
}
});
}

Client-Side Instrumentation

Web Vitals and User Experience

import { getCLS, getFID, getFCP, getLCP, getTTFB, Metric } from "web-vitals";
// Custom function to send metrics to your analytics endpoint
function sendToAnalytics(metric: Metric, labels: Record<string, string> = {}) {
// Send to your API endpoint or directly to Last9
fetch("/api/analytics/web-vitals", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
delta: metric.delta,
rating: metric.rating,
...labels,
}),
}).catch(console.error);
}
export function reportWebVitals() {
const labels = {
page: window.location.pathname,
referrer: document.referrer || "direct",
user_agent: navigator.userAgent,
};
getCLS((metric) => sendToAnalytics(metric, labels));
getFID((metric) => sendToAnalytics(metric, labels));
getFCP((metric) => sendToAnalytics(metric, labels));
getLCP((metric) => sendToAnalytics(metric, labels));
getTTFB((metric) => sendToAnalytics(metric, labels));
}

Pages Router Integration

import type { NextApiRequest, NextApiResponse } from "next";
import { tracer, nextjsMetrics, withTracing } from "@/lib/telemetry";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
return withTracing("pages_api.legacy_users", async (span) => {
const startTime = Date.now();
try {
span.setAttributes({
"api.method": req.method,
"api.route": "/api/legacy-users",
"api.type": "pages_router",
});
if (req.method === "GET") {
// Simulate database query
await new Promise((resolve) => setTimeout(resolve, 100));
const users = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
name: `Legacy User ${i + 1}`,
email: `legacy${i + 1}@example.com`,
}));
span.setAttributes({
"users.count": users.length,
operation: "list_users",
});
// Record metrics
const duration = Date.now() - startTime;
nextjsMetrics.apiCalls.add(1, {
method: "GET",
route: "/api/legacy-users",
status_code: "200",
router_type: "pages",
});
nextjsMetrics.renderDuration.record(duration, {
component: "pages_api_users",
operation: "list",
});
res.status(200).json({ users });
} else {
span.setAttributes({
"error.type": "method_not_allowed",
});
res.setHeader("Allow", ["GET"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
} catch (error) {
span.recordException(error as Error);
nextjsMetrics.errorCount.add(1, {
error_type: "pages_api_error",
operation: "legacy_users",
});
res.status(500).json({ error: "Internal Server Error" });
}
});
}

Production Deployment

Environment Configuration

# OpenTelemetry Configuration
OTEL_EXPORTER_OTLP_ENDPOINT=$last9_otlp_endpoint
OTEL_EXPORTER_OTLP_HEADERS=Authorization=$last9_otlp_auth_header
OTEL_SERVICE_NAME=nextjs-app
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.0.0
# Next.js Configuration
NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1
# Optional: Custom configurations
DATABASE_URL=your-database-url
REDIS_URL=your-redis-url

Testing the Integration

  1. Start your application

    npm run dev
    # or for production
    npm run build && npm start
  2. Test different routes

    # App Router pages
    curl http://localhost:3000/users
    curl http://localhost:3000/users/1
    # API routes
    curl http://localhost:3000/api/users
    curl http://localhost:3000/api/users/1
    # Pages Router (if implemented)
    curl http://localhost:3000/legacy-users
    curl http://localhost:3000/api/legacy-users
  3. View telemetry in Last9

    Check your Last9 dashboard for:

    • Server-side rendering traces with Next.js specific attributes
    • API route spans with proper context propagation
    • Client-side web vitals metrics
    • Database and external API call traces
    • Custom business logic spans and metrics