Guides
Guide

Next.js Logging: Pino, instrumentation.ts, and Request IDs

By Sazabi Team

Last verified on Nov 8, 2026.

Next.js has no production logger built in. The logging option in next.config.js shapes what you see in the terminal during next dev, and nothing more. For production you add a JSON logger such as pino, import it where you handle requests, hook server errors through instrumentation.ts, and attach a request ID and trace ID. This guide shows each piece in working code, tested on a Next.js 16 App Router project.

What the Built-In logging Option Does

The logging key in next.config.js controls development output only. The docs list four settings:

OptionEffect
logging.fetches.fullUrlLog the full URL of fetch calls
logging.serverFunctionsShow or hide Server Function calls (shown by default)
logging.incomingRequestsfalse, or { ignore: [/regex/] } to hide noisy routes
logging.browserToTerminalForward browser console output to the terminal

The incoming-request docs say this "doesn't affect production builds." So do not rely on it for an access log. What reaches stdout in production is whatever your code writes plus Next.js's own error output, which is plain text and not JSON.

Create a Shared Logger

npm install pino @opentelemetry/api
// lib/logger.ts
import pino from 'pino'
import { trace } from '@opentelemetry/api'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'web' },
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: { level: (label) => ({ level: label }) },
  redact: ['req.headers.authorization', 'password'],
  mixin() {
    const ctx = trace.getActiveSpan()?.spanContext()
    return ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}
  },
})

You normally need no bundler configuration. Next.js ships a default list of packages it does not bundle on the server, and pino, pino-pretty, and pino-roll are on it. If you use another logger that fails to bundle, add it to serverExternalPackages in next.config.js. For pino options in detail, see the Pino guide.

Use pino in code that runs on the Node.js runtime. If you opt a route into the Edge runtime, log there with console and a JSON string, and keep the pino import out of files that Edge code loads.

Log From Route Handlers With a Request ID

Read the request ID from a header your proxy sets, generate one if it is missing, and create a child logger:

// app/api/order/route.ts
import { after } from 'next/server'
import { headers } from 'next/headers'
import { randomUUID } from 'node:crypto'
import { logger } from '@/lib/logger'

export async function POST() {
  const h = await headers()
  const requestId = h.get('x-request-id') ?? randomUUID()
  const log = logger.child({ request_id: requestId })

  log.info({ order_id: 4812 }, 'order placed')
  after(() => {
    log.info('response sent')
  })

  return Response.json({ ok: true }, { headers: { 'x-request-id': requestId } })
}

Running this build and sending a request with x-request-id: req-8f3a produced:

{"level":"info","time":"2026-11-08T06:58:30.313Z","service":"web","request_id":"req-8f3a","order_id":4812,"msg":"order placed"}
{"level":"info","time":"2026-11-08T06:58:30.321Z","service":"web","request_id":"req-8f3a","msg":"response sent"}

after from next/server schedules work that runs once the response finishes, which the docs describe as suited to logging and analytics, so slow log calls do not delay the response. The shared request_id is a correlation ID. Always return it in a response header so a support ticket can quote it.

Hook Server Errors With instrumentation.ts

Create instrumentation.ts in the project root, or inside src if you use one, next to app and pages rather than inside them. Its register function runs once when a new server instance starts. Next.js calls it in every runtime, so guard anything Node-only with NEXT_RUNTIME:

// instrumentation.ts
import type { Instrumentation } from 'next'

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { logger } = await import('./lib/logger')
    logger.info('server started')
  }
}

export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { logger } = await import('./lib/logger')
    logger.error(
      {
        err,
        digest: (err as { digest?: string }).digest,
        method: request.method,
        path: request.path,
        route: context.routePath,
        route_type: context.routeType,
      },
      'request error'
    )
  }
}

onRequestError is the useful one. Next.js calls it whenever the server captures an error, passing the error, a read-only request with path, method, and headers, and a context that says which router and route type failed (render, route, action, or proxy). A route that throws produced this line in testing, in addition to Next.js's own unstructured error output:

{
  "level": "error",
  "time": "2026-11-08T06:58:30.139Z",
  "service": "web",
  "err": { "type": "Error", "message": "db down", "stack": "..." },
  "method": "GET",
  "path": "/api/boom",
  "route": "/api/boom",
  "route_type": "route",
  "msg": "request error"
}

Two cautions from the docs. If you do async work in onRequestError, await it. And the error you receive may not be the original instance, because React can process errors during Server Components rendering. Use the digest property to identify the underlying error, and log it as above. Do not log request.headers wholesale, since it carries cookies and authorization.

Add Trace IDs With OpenTelemetry

Next.js supports OpenTelemetry out of the box and recommends it. The shortest setup uses @vercel/otel:

npm install @vercel/otel @opentelemetry/api
// instrumentation.ts
import { registerOTel } from '@vercel/otel'

export function register() {
  registerOTel({ serviceName: 'next-app' })
}

With a tracer provider registered, the mixin in the logger above adds trace_id and span_id to every line logged inside a request's span, because spans you create while handling a request join that request's trace. Spans created inside register itself do not join a request trace. For a manual setup, the docs warn that NodeSDK is incompatible with the Edge runtime, so import it only when NEXT_RUNTIME is nodejs. See the OpenTelemetry Node.js guide for the SDK details and the trace ID glossary entry for what the value means.

Common Mistakes

  • Expecting logging in next.config.js to log production requests. It is development only.
  • Importing the logger at the top of instrumentation.ts. register also runs for the Edge runtime. Use a dynamic import inside a NEXT_RUNTIME check.
  • Using pino-pretty in production. Keep it for next dev.
  • Logging whole request objects in onRequestError. Headers carry credentials.
  • Not awaiting async work in onRequestError. A fire-and-forget fetch may never finish.
  • Logging in client components and expecting server visibility. Browser console output stays in the browser unless you send it to an endpoint.

Production Tips

  • Write JSON to stdout, one line per event, and let the platform forward it. See the JSON logging guide.
  • Set LOG_LEVEL through the environment.
  • Pick field names once and reuse them across services using the logging best practices guide.
  • On Vercel, see Vercel logs for reading and exporting what your functions write.

Sending Next.js Logs to Sazabi

Sazabi, an AI-native observability and security platform, connects to Vercel directly. Per its Vercel docs, you connect a Vercel API token and Sazabi sets up log, trace, and Web Analytics delivery for the projects you select. The Vercel team needs to be on the Pro or Enterprise plan.

Sazabi ingests OpenTelemetry metrics (Prometheus metrics through an OpenTelemetry Collector) and OTLP traces through the same intake as your logs. It stores each metric data point and each span as a log record that its agent queries alongside your logs, so there is no separate metrics store to run.

If you self-host, set the OTLP environment variables described in the OpenTelemetry integration and send traces from @vercel/otel or the Node SDK to Sazabi's intake. For the 5xx lines onRequestError produces, see crash loops.

Frequently Asked Questions

How do I log in a Next.js app in production?

Create a shared pino logger, import it in route handlers and server actions, and write JSON to stdout. The logging option in next.config.js only affects development.

Does the Next.js logging config work in production?

No. The logging options for fetches, incoming requests, and Server Functions apply when running in development mode and do not affect production builds.

Can I use pino in the Edge runtime?

Do not rely on it. Keep pino in Node.js runtime code and guard any import in instrumentation.ts with a check that NEXT_RUNTIME is nodejs.

How do I log unhandled server errors in Next.js?

Export an onRequestError function from instrumentation.ts. Next.js calls it with the error, the request, and the route context, and you can log those fields as JSON.

Sources

  1. The logging option in next.config.js configures fetch, Server Function, incoming request, and browser console logging in development only (accessed Nov 8, 2026)
  2. register runs once when a server instance starts, onRequestError receives the error, request, and context, and NEXT_RUNTIME selects the runtime (accessed Nov 8, 2026)
  3. instrumentation.ts lives in the project root or src folder, and register is called in all environments so runtime-specific code needs a NEXT_RUNTIME check (accessed Nov 8, 2026)
  4. Next.js supports OpenTelemetry out of the box, @vercel/otel registers it, NodeSDK is not compatible with the edge runtime, and spans created in a request join that request's trace (accessed Nov 8, 2026)
  5. pino, pino-pretty, and pino-roll are in the default list of packages opted out of server bundling (accessed Nov 8, 2026)
  6. after schedules work to run after the response finishes, which suits logging and analytics (accessed Nov 8, 2026)
  7. Connecting a Vercel account lets Sazabi set up log, trace, and Web Analytics delivery for selected projects; the Vercel team needs a Pro or Enterprise plan (accessed Nov 8, 2026)