Next.js
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.
Set up evlog in my Next.js app with wide events and structured errors.
- Install evlog: pnpm add evlog
- Create lib/evlog.ts with createEvlog() to export withEvlog, useLogger, createError
- Set service name and optional sampling/drain config
- 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/frameworks/nextjs
Adapters: https://www.evlog.dev/adapters
Quick Start
1. Install
bun add evlog
2. Create your evlog instance
import { createEvlog } from 'evlog/next'
export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'my-app',
})
3. Wrap a route handler
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 file at the project root for server startup hooks and error reporting. evlog provides createInstrumentation() to integrate with this pattern.
createEvlog()— per-request wide events viawithEvlog()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, socreateEvlog()respects it. Each can have its owndrain.
1. Add instrumentation exports to your evlog instance
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createFsDrain } from 'evlog/fs'
export const { register, onRequestError } = createInstrumentation({
service: 'my-app',
drain: createFsDrain(),
captureOutput: true,
})
2. Wire up instrumentation.ts
Next.js evaluates instrumentation.ts in both Node.js and Edge runtimes. Load your real lib/evlog.ts only when NEXT_RUNTIME === 'nodejs' so Edge bundles never pull Node-only drains (fs, adapters, etc.).
Recommended — defineNodeInstrumentation gates the Node runtime, dynamic-imports your module once (cached), and forwards register / onRequestError:
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
Manual — same behavior with explicit handlers; use this if you want full control in the root file (extra branches, per-error logic, or a different import strategy). Without a shared helper, each onRequestError typically re-runs import('./lib/evlog') unless you add your own cache.
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { register } = await import('./lib/evlog')
await register()
}
}
export async function onRequestError(
error: { digest?: string } & Error,
request: { path: string; method: string; headers: Record<string, string> },
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { onRequestError } = await import('./lib/evlog')
await onRequestError(error, request, context)
}
}
Both styles are supported: the helper is optional sugar, not a takeover. defineNodeInstrumentation only forwards Next’s two hooks to whatever you export from lib/evlog — it does not prevent other work in your app.
Custom behavior (evlog + your code)
- Root
instrumentation.ts— Next’s stable surface here isregisterandonRequestError. The evlog helper exports exactly those; it does not reserve the whole file. If you need additional top-level exports later (when Next documents them), use the manual wiring and compose by hand, or keep evlog’s hooks minimal and put everything else inlib/evlog.ts. lib/evlog.ts(recommended for composition) — wrap evlog’s handlers so you stay free to add startup work, metrics, or extra logging without fighting the helper:
import { createInstrumentation } from 'evlog/next/instrumentation'
const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
service: 'my-app',
drain: myDrain,
})
export async function register() {
await evlogRegister()
// e.g. OpenTelemetry, feature flags, custom one-off init
}
export function onRequestError(
error: { digest?: string } & Error,
request: { path: string; method: string; headers: Record<string, string> },
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
evlogOnRequestError(error, request, context)
// optional: your own side effects (metrics, etc.)
}
Then keep instrumentation.ts as a thin import (defineNodeInstrumentation or manual) that only loads ./lib/evlog on Node — your customization lives next to createEvlog() in one place.
Next.js automatically calls these exports:
register()— Runs once when the server starts. Initializes the evlog logger with your configured drain, sampling, and options. WhencaptureOutputis enabled,stdoutandstderrwrites 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).
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 alongside the original output.Configuration
The createInstrumentation() factory accepts global logger options (enabled, service, env, pretty, silent, sampling, stringify, drain) plus:
| Option | Type | Default | Description |
|---|---|---|---|
captureOutput | boolean | false | Capture stdout/stderr as structured log events |
Production Configuration
A real-world lib/evlog.ts with enrichers, batched drain, tail sampling, and route-based service names:
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:
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:
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:
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):
{
"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:
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, whether it's a fetch response, an EvlogError, or a plain Error object:
'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
enabled, pretty, silent, sampling, middleware options, etc.).The createEvlog() factory accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
service | string | 'app' | Service name shown in logs |
environment | string | Auto-detected | Environment name |
include | string[] | undefined | Route patterns to log |
exclude | string[] | undefined | Route patterns to exclude |
routes | Record<string, RouteConfig> | undefined | Route-specific service configuration |
sampling.rates | object | undefined | Head sampling rates per log level |
sampling.keep | array | undefined | Tail sampling conditions |
keep | (ctx: TailSamplingContext) => void | undefined | Custom tail sampling callback |
drain | DrainFunction | undefined | Drain adapter for external services |
enrich | (ctx: EnrichContext) => void | undefined | Event enrichment callback |
Tail Sampling
Combine rule-based and custom tail sampling to always capture what matters, even when head sampling drops most logs:
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:
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = {
matcher: ['/api/:path*'],
}
middleware.ts instead of proxy.ts. The evlog middleware works with both, so just import from evlog/next regardless.Server Actions
withEvlog() also works with Server Actions. Wrap your action to get full request-scoped logging:
'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 transport:
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:
'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:
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.