Skip to content
Last9
Book demo

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

Use the same service and environment values across Next.js traces, ECS logs, and dashboards. If your ECS task definition emits logs with service.name=checkout-service and deployment.environment=production, set OTEL_SERVICE_NAME=checkout-service and include deployment.environment=production in OTEL_RESOURCE_ATTRIBUTES. Mismatched values split one application across multiple services or environments in Last9.

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

Troubleshooting

  • No traces appearing in Last9. Confirm instrumentation.ts is at the project root (or src/) and exports a register() function. On Next.js versions below 15, enable experimental.instrumentationHook in next.config.js. Verify OTEL_EXPORTER_OTLP_ENDPOINT and the Last9 authorization header are set in the environment the app actually runs in.
  • Instrumentation hook not firing. The hook runs only in the Node.js runtime. If a route sets export const runtime = "edge", the OpenTelemetry Node SDK will not initialize there. Keep instrumented routes on the Node.js runtime.
  • Client-side Web Vitals not reported. Web Vitals are collected in the browser, so confirm the client instrumentation loads on the page and that an ad blocker or Content Security Policy is not blocking requests to the Last9 endpoint.
  • Duplicate or missing spans. Running more than one OpenTelemetry SDK (for example, a platform-provided tracer alongside this setup) double-instruments requests. Initialize a single tracer provider and ensure the SDK is not registered more than once.
  • One application split across multiple services or environments. Set the same OTEL_SERVICE_NAME and deployment.environment values across server traces, ECS logs, and dashboards. Mismatched values split one application into separate services or environments in Last9.
  • Spans not exported on serverless or Vercel deployments. Short-lived functions can exit before telemetry is flushed. Flush the span processor before the function returns, or use a setup that forces a flush on shutdown.

Please get in touch with us on Discord or Email if you have any questions.