---
title: "Next.js"
description: "Wide events, structured errors, drain pipeline, tail sampling, route-based services, error handling, and client-side logging in Next.js applications."
canonical_url: "https://www.evlog.dev/integrate/frameworks/nextjs"
---
# Next.js

> Wide events, structured errors, drain pipeline, tail sampling, route-based services, error handling, and client-side logging in Next.js applications.

evlog integrates with Next.js App Router via a `createEvlog()` factory that provides `withEvlog()` handler wrapper, `useLogger()`, and typed exports. One file, zero global state.

<prompt :actions="["copy","cursor","claude"]" description="Set up evlog in my Next.js app" icon="i-simple-icons-nextdotjs">

Set up evlog in my Next.js app with wide events and structured errors.

- Install evlog: pnpm add evlog
- Create evlog.config.ts with export default defineEvlog({ service: 'my-app' }); put sampling, drain, enrich and route filters there
- Create lib/evlog.ts with createEvlog(config), importing config from evlog.config.ts, to export withEvlog, useLogger, createError
- Wrap API route handlers with withEvlog()
- Use useLogger() inside handlers to build wide events with log.set()
- Throw errors with createError({ message, status, why, fix })
- Wide events are auto-emitted when each request completes

Docs: [https://www.evlog.dev/integrate/frameworks/nextjs](https://www.evlog.dev/integrate/frameworks/nextjs)
Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview)

</prompt>

## Quick Start

### 1. Install

<code-group>

```bash [pnpm]
pnpm add evlog
```

```bash [bun]
bun add evlog
```

```bash [yarn]
yarn add evlog
```

```bash [npm]
npm install evlog
```

</code-group>

### 2. Create evlog.config.ts

```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'my-app',
})
```

Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration).

### 3. Create your evlog instance

```typescript [lib/evlog.ts]
import { createEvlog } from 'evlog/next'
import config from '../evlog.config'

export const { withEvlog, useLogger, log, createError } = createEvlog(config)
```

`createEvlog(config)` starts the logger when `lib/evlog.ts` is first imported, and the routes wrapped with `withEvlog` use the file's route filters, drain, `enrich` and `keep`. To also drain `log.*` calls made outside a request, report unhandled errors, and apply the file's `environment`, start the logger from the file in `instrumentation.ts` as shown in [Start the logger from evlog.config.ts](#start-the-logger-from-evlogconfigts).

### 4. Wrap a route handler

```typescript [app/api/hello/route.ts]
import { withEvlog, useLogger } from '@/lib/evlog'

export const GET = withEvlog(async () => {
  const log = useLogger()
  log.set({ action: 'hello' })
  return Response.json({ message: 'Hello!' })
})
```

## Instrumentation

Next.js supports an [`instrumentation.ts`](https://nextjs.org/docs/app/guides/instrumentation) file at the project root for server startup hooks and error reporting. evlog provides `createInstrumentation()` to integrate with this pattern.

<callout color="info" icon="i-lucide-info">

These two APIs serve different purposes and can be used independently or together:

- **createEvlog()**: per-request wide events via `withEvlog()`
- **createInstrumentation()**: server startup (`register()`) + unhandled error reporting (`onRequestError()`) across all routes, including SSR and RSC
- Both can coexist: `register()` initializes and locks the logger first, so `createEvlog()` respects it. Each can have its own `drain`.

</callout>

### 1. Split instrumentation from route config

Keep Node-only imports (`evlog/fs`, heavy adapters) out of root `instrumentation.ts`. Use `defineNodeInstrumentation` with an options object. evlog loads `createInstrumentation` on Node.js only, without a visible `import()` in your file.

- Root `instrumentation.ts` → `defineNodeInstrumentation({ service, ... })`
- `lib/evlog.ts` → `createEvlog()` and Node-only drains for API routes

```typescript [instrumentation.ts]
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation({
  service: 'my-app',
  captureOutput: true,
})
```

```typescript [lib/evlog.ts]
import { createEvlog } from 'evlog/next'
import { createFsDrain } from 'evlog/fs'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createFsDrain(),
})
```

### 2. Wire up instrumentation.ts

Next.js evaluates `instrumentation.ts` in both Node.js and Edge runtimes. `defineNodeInstrumentation` gates on `NEXT_RUNTIME === 'nodejs'` and loads the Node-only factory internally.

### Custom behavior (evlog + your code)

Pass a **loader callback** when you need extra startup work alongside evlog:

```typescript [instrumentation.ts]
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(async () => {
  const { createInstrumentation } = await import('evlog/next/instrumentation/create')
  const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
    service: 'my-app',
    captureOutput: true,
  })

  return {
    async register() {
      await evlogRegister()
      // e.g. OpenTelemetry, feature flags, custom one-off init
    },
    onRequestError: evlogOnRequestError,
  }
})
```

Keep `lib/evlog.ts` for `createEvlog()` and Node-only drains. Route handlers import `@/lib/evlog`.

Next.js automatically calls these exports:

- `register()`: Runs once when the server starts. Initializes the evlog logger with your configured drain, sampling, and options. When `captureOutput` is enabled, `stdout` and `stderr` writes are captured as structured log events.
- `onRequestError()`: Called on every unhandled request error. Emits a structured error log with the error message, digest, stack trace, request path/method, and routing context (`routerKind`, `routePath`, `routeType`, `renderSource`).

<callout color="info" icon="i-lucide-info">

`captureOutput` only activates in the Node.js runtime (`NEXT_RUNTIME === 'nodejs'`). It patches `process.stdout.write` and `process.stderr.write` to emit structured `log.info` / `log.error` events. When `silent` is `false` (the default), captured output is shown once as structured terminal output, and the raw write is not duplicated. evlog's own output passes through the capture, so a drain receives each wide event once. Set `silent: true` to keep the original passthrough alongside drain delivery. Known Next.js Edge bundler warnings are filtered by default so they are not re-emitted as evlog errors.

</callout>

### Configuration

`defineNodeInstrumentation()` and `createInstrumentation()` accept global logger options (`enabled`, `service`, `env`, `pretty`, `silent`, `sampling`, `stringify`, `drain`) plus:

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        captureOutput
      </code>
    </td>
    
    <td>
      <code>
        boolean | CaptureOutputOptions
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
    </td>
    
    <td>
      Capture stdout/stderr as structured log events
    </td>
  </tr>
</tbody>
</table>

`CaptureOutputOptions` fields:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        stdout
      </code>
    </td>
    
    <td>
      <code>
        boolean
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
    
    <td>
      Capture stdout writes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        stderr
      </code>
    </td>
    
    <td>
      <code>
        boolean
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
    
    <td>
      Capture stderr writes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ignore
      </code>
    </td>
    
    <td>
      <code>
        (string | RegExp)[]
      </code>
    </td>
    
    <td>
      Next.js Edge bundler warnings
    </td>
    
    <td>
      Skip re-emitting matching chunks as log events
    </td>
  </tr>
</tbody>
</table>

```typescript [instrumentation.ts]
defineNodeInstrumentation({
  captureOutput: {
    stderr: true,
    ignore: [/my-noisy-dep/, 'benign warning'],
  },
})
```

## Production Configuration

A real-world `lib/evlog.ts` with enrichers, batched drain, tail sampling, and route-based service names:

<code-collapse>

```typescript [lib/evlog.ts]
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

// 1. Enrichers - add derived context to every event
const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()]

// 2. Pipeline - batch events before sending
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50, intervalMs: 5000 } })

// 3. Drain - send batched events to Axiom
const drain = pipeline(createAxiomDrain({
  dataset: 'logs',
  apiKey: process.env.AXIOM_API_KEY!,
}))

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',

  // 4. Head sampling - keep 10% of info logs
  sampling: {
    rates: { info: 10 },
    keep: [
      { status: 400 },              // Always keep errors
      { duration: 1000 },           // Always keep slow requests
      { path: '/api/critical/**' }, // Always keep critical paths
    ],
  },

  // 5. Route-based service names
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
    '/api/booking/**': { service: 'booking-service' },
  },

  // 6. Custom tail sampling - business logic
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },

  // 7. Enrich every event with user agent, request size, and deployment info
  enrich: (ctx) => {
    for (const enricher of enrichers) enricher(ctx)
    ctx.event.deploymentId = process.env.VERCEL_DEPLOYMENT_ID
    ctx.event.region = process.env.VERCEL_REGION
  },

  drain,
})
```

</code-collapse>

<callout color="neutral" icon="i-lucide-cloud">

On Vercel, `withEvlog` registers drain work with Next's `after()`, and with a pipeline it also registers `drain.settled()`: the function stays alive until the buffered batch is delivered, or dropped once retries are exhausted. `batch.intervalMs` bounds when the first delivery attempt starts; retry backoff can extend the lifetime past it. If your ingest endpoint is slow enough to hit the default 5s request timeout, raise it (`createAxiomDrain({ timeout: 15000 })`): a timed-out request that actually landed gets retried, and non-idempotent ingest APIs count that twice.

</callout>

## Wide Events

Build up context progressively through your handler. One request = one wide event:

```typescript [app/api/checkout/route.ts]
import { withEvlog, useLogger } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  // Stage 1: User context
  log.set({
    user: { id: body.userId, plan: 'enterprise' },
  })

  // Stage 2: Cart context
  log.set({
    cart: { items: body.items.length, total: body.total, currency: 'USD' },
  })

  // Stage 3: Payment context
  const payment = await processPayment(body)
  log.set({
    payment: { method: payment.method, cardLast4: payment.last4 },
  })

  return Response.json({ success: true, orderId: payment.orderId })
})
```

All fields are merged into a single wide event emitted when the handler completes (or when a streaming response body finishes, so AI SDK metadata is included):

```bash [Output (Pretty)]
10:23:45.612 INFO [my-app] POST /api/checkout 200 in 145ms
  ├─ user: id=usr_123 plan=enterprise
  ├─ cart: items=3 total=14999 currency=USD
  ├─ payment: method=card cardLast4=4242
  └─ requestId: a1b2c3d4-...
```

## Background work (`log.fork`)

Inside `withEvlog`, `useLogger()` returns a logger with **fork** for child wide events. See [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events.

```typescript [app/api/orders/route.ts]
import { withEvlog, useLogger } from '@/lib/evlog'

export const POST = withEvlog(async () => {
  const log = useLogger()
  log.fork!('enqueue', async () => {
    const child = useLogger()
    child.set({ job: 'queued' })
  })
  return Response.json({ ok: true })
})
```

## Error Handling

Use `createError` for structured errors with `why`, `fix`, and `link` fields that help developers debug in both logs and API responses:

<code-collapse>

```typescript [app/api/payment/process/route.ts]
import { withEvlog, useLogger, createError } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  log.set({ payment: { amount: body.amount } })

  if (body.amount <= 0) {
    throw createError({
      status: 400,
      message: 'Invalid payment amount',
      why: 'The amount must be a positive number',
      fix: 'Pass a positive integer in cents (e.g. 4999 for $49.99)',
      link: 'https://docs.example.com/api/payments#amount',
    })
  }

  const result = await chargeCard(body)

  if (!result.success) {
    log.error(new Error(`Payment declined: ${result.reason}`))
    throw createError({
      status: 402,
      message: 'Payment declined',
      why: `Card declined by issuer: ${result.reason}`,
      fix: 'Try a different payment method or contact your bank',
    })
  }

  return Response.json({ success: true })
})
```

</code-collapse>

`withEvlog()` catches `EvlogError` and returns a structured JSON response (like Nitro does for Nuxt):

```json [Response (402)]
{
  "name": "EvlogError",
  "message": "Payment declined",
  "status": 402,
  "data": {
    "why": "Card declined by issuer: insufficient_funds",
    "fix": "Try a different payment method or contact your bank"
  }
}
```

In the terminal, the error renders inside the wide event: error block first, then request context. Colors and tree connectors render in the terminal; the example below omits ANSI for readability.

```bash [Terminal output]
ERROR [app] POST /api/payment/process 402 in 12ms
  ├─ error: Payment declined
  │     at app/api/payment/process/route.ts:336
  │   ❯ 336 ┃ throw createError({ message: 'Payment declined', ... })
  │     Why: Card declined by issuer: insufficient_funds
  │     Fix: Try a different payment method or contact your bank
  │     stack (3 frames hidden in node_modules)
  └─ payment: amount=4999
```

### Parsing Errors on the Client

Use `parseError` to extract the structured fields from any error, whether it's a fetch response, an `EvlogError`, or a plain `Error` object:

```tsx [app/components/PaymentForm.tsx]
'use client'
import { parseError } from 'evlog'

async function handleSubmit(formData: FormData) {
  try {
    const res = await fetch('/api/payment/process', {
      method: 'POST',
      body: JSON.stringify({ amount: Number(formData.get('amount')) }),
    })
    if (!res.ok) throw { data: await res.json(), status: res.status }
  } catch (error) {
    const { message, status, why, fix, link } = parseError(error)
    // message: "Payment declined"
    // why: "Card declined by issuer: insufficient_funds"
    // fix: "Try a different payment method or contact your bank"
  }
}
```

`parseError` normalizes any error shape into a flat `{ message, status, why?, fix?, link? }` object, so your UI code never has to dig through nested `data.data` or check for different error formats.

## Configuration

<callout color="info" icon="i-lucide-book-open">

See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list of shared options (`enabled`, `pretty`, `silent`, `sampling`, middleware options, etc.).

</callout>

The `createEvlog()` factory accepts the following options:

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        service
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        'app'
      </code>
    </td>
    
    <td>
      Service name shown in logs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        environment
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      Auto-detected
    </td>
    
    <td>
      Environment name
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        include
      </code>
    </td>
    
    <td>
      <code>
        string[]
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Route patterns to log
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        exclude
      </code>
    </td>
    
    <td>
      <code>
        string[]
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Route patterns to exclude
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        routes
      </code>
    </td>
    
    <td>
      <code>
        Record<string, RouteConfig>
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Route-specific service configuration
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sampling.rates
      </code>
    </td>
    
    <td>
      <code>
        object
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Head sampling rates per log level
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sampling.keep
      </code>
    </td>
    
    <td>
      <code>
        array
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Tail sampling conditions
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        keep
      </code>
    </td>
    
    <td>
      <code>
        (ctx: TailSamplingContext) => void
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Custom tail sampling callback
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        drain
      </code>
    </td>
    
    <td>
      <code>
        DrainFunction
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Drain adapter for external services
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        enrich
      </code>
    </td>
    
    <td>
      <code>
        (ctx: EnrichContext) => void
      </code>
    </td>
    
    <td>
      <code>
        undefined
      </code>
    </td>
    
    <td>
      Event enrichment callback
    </td>
  </tr>
</tbody>
</table>

### Start the logger from evlog.config.ts

Read the file in `lib/evlog.ts` for both the instrumentation and `createEvlog()`:

```typescript [lib/evlog.ts]
import { toLoggerConfig } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createInstrumentation } from 'evlog/next/instrumentation/create'
import config from '../evlog.config'

export const { register, onRequestError } = createInstrumentation({
  ...toLoggerConfig(config),
  captureOutput: true,
})

export const { withEvlog, useLogger, log, createError } = createEvlog(config)
```

```typescript [instrumentation.ts]
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
```

`defineNodeInstrumentation` loads `lib/evlog.ts` on Node.js only, so the drains the config imports stay out of the Edge bundle. `register()` starts the logger from the file before any route runs, and `createEvlog()` takes the route filters, `enrich` and `keep` from it. [`evlog init`](https://www.evlog.dev/cli/init#nextjs) writes both files for you.

## Tail Sampling

Combine rule-based and custom tail sampling to always capture what matters, even when head sampling drops most logs:

```typescript [lib/evlog.ts]
export const { withEvlog, useLogger } = createEvlog({
  service: 'my-app',
  sampling: {
    rates: { info: 10 }, // Only keep 10% of info logs
    keep: [
      { status: 400 },              // Always keep 4xx/5xx
      { duration: 1000 },           // Always keep slow requests
      { path: '/api/critical/**' }, // Always keep critical paths
    ],
  },
  // Custom: always keep premium user requests
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },
})
```

The `keep` rules use OR logic: any match forces the event through regardless of head sampling.

## Middleware

Set `x-request-id` and `x-evlog-start` headers so `withEvlog()` can correlate timing across the middleware -> handler chain:

```typescript [proxy.ts]
import { evlogMiddleware } from 'evlog/next'

export const proxy = evlogMiddleware()

export const config = {
  matcher: ['/api/:path*'],
}
```

<callout color="info" icon="i-lucide-info">

Older versions of Next.js use `middleware.ts` instead of `proxy.ts`. The evlog middleware works with both, so just import from `evlog/next` regardless.

</callout>

## Server Actions

`withEvlog()` also works with Server Actions. Wrap your action to get full request-scoped logging:

```typescript [app/actions/checkout.ts]
'use server'
import { withEvlog, useLogger } from '@/lib/evlog'

export const checkout = withEvlog(async (formData: FormData) => {
  const log = useLogger()
  log.set({ action: 'checkout', cartId: formData.get('cartId') })
  // ...
})
```

## Client Provider

Wrap your root layout with `EvlogProvider` to enable client-side logging and its drain:

```tsx [app/layout.tsx]
import { EvlogProvider } from 'evlog/next/client'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <EvlogProvider service="my-app" transport={{ enabled: true }}>
          {children}
        </EvlogProvider>
      </body>
    </html>
  )
}
```

## Client Logging

Use `log` in any client component. Identity is preserved across all logs and transported to the server:

```tsx [app/components/Dashboard.tsx]
'use client'
import { log, setIdentity, clearIdentity } from 'evlog/next/client'

export function Dashboard({ user }: { user: { id: string } }) {
  // Set identity once - all subsequent logs include it
  useEffect(() => {
    setIdentity({ userId: user.id })
    return () => clearIdentity()
  }, [user.id])

  return (
    <button onClick={() => log.info({ action: 'export_clicked', format: 'csv' })}>
      Export
    </button>
  )
}
```

## HTTP drain

For advanced use cases, send structured `DrainContext` events directly from the browser to a custom endpoint:

```typescript [lib/http-drain.ts]
import { createHttpLogDrain } from 'evlog/http'

const drain = createHttpLogDrain({
  drain: { endpoint: '/api/evlog/http-ingest' },
  pipeline: { batch: { size: 10, intervalMs: 5000 } },
})

drain(drainEvent)
await drain.flush()
```

The server endpoint receives batched events:

```typescript [app/api/evlog/http-ingest/route.ts]
export async function POST(request: Request) {
  const events = await request.json()
  // Forward to your drain pipeline, Axiom, etc.
  return new Response(null, { status: 204 })
}
```

## Run Locally

```bash [Terminal]
git clone https://github.com/evloghq/evlog.git
cd evlog/examples/nextjs
pnpm install
pnpm run dev
```

Open [http://localhost:3000](http://localhost:3000) to explore the example.

<card-group>
<card icon="i-simple-icons-github" title="Source Code" to="https://github.com/evloghq/evlog/tree/main/examples/nextjs">

Browse the complete Next.js example source on GitHub.

</card>
</card-group>

## Next Steps

Deepen your **Next.js** integration:

- [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering
- [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more
- [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling
- [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields

---

- [Source Code](https://github.com/evloghq/evlog/tree/main/examples/nextjs)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
