Examples

Next.js App Router

Source Code
Practical patterns for evlog with Next.js — enrichers, drain pipeline, tail sampling, route-based services, error handling, and client-side logging.

Practical patterns for using evlog with Next.js App Router. Each section shows how to activate a feature and the recommended way to use it.

Production Configuration

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

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',
  token: process.env.AXIOM_TOKEN!,
}))

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,
})

Wide Events

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

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:

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-...

Error Handling

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

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 })
})

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

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 with colored output:

Terminal output
Error: Payment declined
Why: Card declined by issuer: insufficient_funds
Fix: Try a different payment method or contact your bank

Parsing Errors on the Client

Use parseError to extract the structured fields from any error — fetch responses, EvlogError, or plain Error objects:

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.

Tail Sampling

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

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:

proxy.ts
import { evlogMiddleware } from 'evlog/next'

export const proxy = evlogMiddleware()

export const config = {
  matcher: ['/api/:path*'],
}
Older versions of Next.js use middleware.ts instead of proxy.ts. The evlog middleware works with both — import from evlog/next regardless.

Client Provider

Wrap your root layout with EvlogProvider to enable client-side logging and transport:

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:

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>
  )
}

Browser Drain

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

import { createBrowserLogDrain } from 'evlog/browser'

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

drain(drainEvent)
await drain.flush()

The server endpoint receives batched events:

app/api/evlog/browser-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

git clone https://github.com/HugoRCD/evlog.git
cd evlog/examples/nextjs
bun install
bun run dev

Open http://localhost:3000 to explore the example.

Source Code

Browse the complete Next.js example source on GitHub.
Copyright © 2026