0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Structured Logging Across Server and Edge

Emit correlated, structured logs that survive serverless cold starts and Edge constraints.

Structured Logging Across Server and Edge is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Structured Logging Matters in Next.js 15

Traditional console.log outputs unstructured strings. In production Next.js 15 apps running across serverless functions and Edge runtimes, these strings are nearly useless: no correlation between requests, no machine-parseable fields, and cold starts discard buffered output before it flushes.

Structured logging solves this by emitting JSON objects with consistent fields on every log line:

  • requestId — ties every log to one HTTP request
  • timestamp — ISO-8601, always UTC
  • levelinfo / warn / error
  • service — which route or function emitted the log
  • message — human-readable summary
  • context — arbitrary structured payload

Observability platforms (Datadog, Axiom, Grafana Loki) ingest these fields automatically, letting you filter and correlate across thousands of concurrent requests in seconds.

The Core Logger Interface

Before integrating with any runtime, define a minimal, portable logger interface. This keeps your business logic decoupled from the concrete logging implementation and makes testing trivial.

Create lib/logger/types.ts with the shape every logger must satisfy:

// lib/logger/types.ts

export type LogLevel = 'debug' | 'info' | 'warn' | 'error';

export interface LogContext {
  requestId?: string;
  userId?: string;
  route?: string;
  durationMs?: number;
  [key: string]: unknown;
}

export interface Logger {
  debug(message: string, context?: LogContext): void;
  info(message: string, context?: LogContext): void;
  warn(message: string, context?: LogContext): void;
  error(message: string, context?: LogContext & { err?: unknown }): void;
}

export interface LogEntry {
  timestamp: string;
  level: LogLevel;
  service: string;
  message: string;
  context: LogContext;
}

Building a JSON Logger for the Node.js Runtime

Next.js 15 Server Components, Route Handlers, and Server Actions run in the Node.js runtime by default. Here you have access to process.stdout and can safely emit multi-line JSON.

The key discipline: write one JSON object per line (NDJSON / JSON Lines format). Log aggregators split on newlines, so multi-line output corrupts ingestion.

Notice how err is serialised manually — JSON.stringify silently drops Error properties like stack and message.

// lib/logger/node-logger.ts
import type { Logger, LogContext, LogEntry, LogLevel } from './types';

function serializeError(err: unknown): Record<string, unknown> {
  if (err instanceof Error) {
    return { name: err.name, message: err.message, stack: err.stack };
  }
  return { raw: String(err) };
}

function createEntry(
  level: LogLevel,
  service: string,
  message: string,
  context: LogContext = {}
): LogEntry {
  return {
    timestamp: new Date().toISOString(),
    level,
    service,
    message,
    context,
  };
}

export function createNodeLogger(service: string): Logger {
  const write = (entry: LogEntry) =>
    process.stdout.write(JSON.stringify(entry) + '\n');

  return {
    debug: (msg, ctx) => write(createEntry('debug', service, msg, ctx)),
    info:  (msg, ctx) => write(createEntry('info',  service, msg, ctx)),
    warn:  (msg, ctx) => write(createEntry('warn',  service, msg, ctx)),
    error: (msg, ctx) => {
      const { err, ...rest } = ctx ?? {};
      write(createEntry('error', service, msg, {
        ...rest,
        ...(err !== undefined ? { error: serializeError(err) } : {}),
      }));
    },
  };
}

Edge-Compatible Logger: Surviving the Constraint

The Edge Runtime (used by Middleware and Route Handlers with export const runtime = 'edge') strips out most Node.js APIs. You cannot use process.stdout.write or fs.

What you can use:

  • console.log / console.error — always available
  • The Fetch API — to forward logs to an external endpoint
  • crypto.randomUUID() — for request IDs

The trick: still emit JSON strings via console.log. Vercel Edge logs are line-buffered and forward to your log drain as plain text, so one JSON per line is safe.

// lib/logger/edge-logger.ts
import type { Logger, LogContext, LogEntry, LogLevel } from './types';

function createEntry(
  level: LogLevel,
  service: string,
  message: string,
  context: LogContext = {}
): LogEntry {
  return {
    timestamp: new Date().toISOString(),
    level,
    service,
    message,
    context,
  };
}

export function createEdgeLogger(service: string): Logger {
  const emit = (entry: LogEntry) => {
    // console.log is the only safe output channel in Edge Runtime
    const line = JSON.stringify(entry);
    if (entry.level === 'error') {
      console.error(line);
    } else {
      console.log(line);
    }
  };

  return {
    debug: (msg, ctx) => emit(createEntry('debug', service, msg, ctx)),
    info:  (msg, ctx) => emit(createEntry('info',  service, msg, ctx)),
    warn:  (msg, ctx) => emit(createEntry('warn',  service, msg, ctx)),
    error: (msg, ctx) => emit(createEntry('error', service, msg, ctx)),
  };
}

Request Correlation with AsyncLocalStorage

The hardest problem in serverless logging is correlation: attaching the same requestId to every log emitted during one request, even deep inside utility functions, without passing the ID as a parameter everywhere.

Node.js AsyncLocalStorage solves this. It creates a per-request context store that propagates automatically through async chains — await, Promise.then, timers — without any explicit threading.

Next.js 15 supports this natively in the Node.js runtime. Create a store module:

// lib/logger/request-store.ts
import { AsyncLocalStorage } from 'async_hooks';

export interface RequestStore {
  requestId: string;
  userId?: string;
  startTime: number;
}

// One singleton for the process lifetime
export const requestStore = new AsyncLocalStorage<RequestStore>();

export function getRequestContext(): Partial<RequestStore> {
  return requestStore.getStore() ?? {};
}

// Wrap any async work in this to bind a store
export function runWithRequestContext<T>(
  store: RequestStore,
  fn: () => Promise<T>
): Promise<T> {
  return requestStore.run(store, fn);
}

Context-Aware Logger Using the Store

Now wire the AsyncLocalStorage store into the logger. Every log call automatically reads requestId and userId from the running context — no prop-drilling needed.

Update lib/logger/node-logger.ts to merge the ambient store before writing:

// lib/logger/context-logger.ts
import { createNodeLogger } from './node-logger';
import { getRequestContext } from './request-store';
import type { Logger, LogContext } from './types';

export function createContextLogger(service: string): Logger {
  const base = createNodeLogger(service);

  function enrich(ctx: LogContext = {}): LogContext {
    const { requestId, userId, startTime } = getRequestContext();
    return {
      ...(requestId ? { requestId } : {}),
      ...(userId    ? { userId }    : {}),
      ...(startTime
        ? { elapsedMs: Date.now() - startTime }
        : {}),
      ...ctx, // caller can override ambient values if needed
    };
  }

  return {
    debug: (msg, ctx) => base.debug(msg, enrich(ctx)),
    info:  (msg, ctx) => base.info(msg,  enrich(ctx)),
    warn:  (msg, ctx) => base.warn(msg,  enrich(ctx)),
    error: (msg, ctx) => base.error(msg, enrich(ctx)),
  };
}

// Singleton used everywhere in Node.js routes
export const logger = createContextLogger('nextjs-app');

Injecting Context in Middleware

The ideal place to assign a requestId is Next.js Middleware — it intercepts every request before routing. You can generate an ID, attach it to a request header (x-request-id), and pass it downstream to both Server Components and API routes.

Note: Middleware runs in the Edge Runtime. Use crypto.randomUUID() (not uuid package) and createEdgeLogger.

// middleware.ts  (project root)
import { NextRequest, NextResponse } from 'next/server';
import { createEdgeLogger } from '@/lib/logger/edge-logger';

const log = createEdgeLogger('middleware');

export function middleware(req: NextRequest) {
  // Honour an upstream gateway's ID if present
  const requestId =
    req.headers.get('x-request-id') ?? crypto.randomUUID();

  const start = Date.now();

  log.info('request started', {
    requestId,
    method: req.method,
    path: req.nextUrl.pathname,
  });

  const response = NextResponse.next();

  // Forward the ID so Route Handlers and Server Actions can read it
  response.headers.set('x-request-id', requestId);

  log.info('request forwarded', {
    requestId,
    durationMs: Date.now() - start,
  });

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Seeding AsyncLocalStorage in a Route Handler

In Node.js Route Handlers, read the x-request-id header (set by Middleware) and seed the AsyncLocalStorage store. Everything called within runWithRequestContext — including nested awaits and utility functions — inherits this context automatically.

// app/api/orders/route.ts
import { type NextRequest, NextResponse } from 'next/server';
import { runWithRequestContext } from '@/lib/logger/request-store';
import { logger } from '@/lib/logger/context-logger';
import { fetchOrders } from '@/lib/orders';

export async function GET(req: NextRequest) {
  const requestId =
    req.headers.get('x-request-id') ?? crypto.randomUUID();

  return runWithRequestContext(
    { requestId, startTime: Date.now() },
    async () => {
      logger.info('fetching orders');
      // up arrow automatically includes requestId + elapsedMs

      try {
        const orders = await fetchOrders();
        logger.info('orders fetched', { count: orders.length });
        return NextResponse.json(orders);
      } catch (err) {
        logger.error('failed to fetch orders', { err });
        return NextResponse.json(
          { error: 'Internal Server Error' },
          { status: 500 }
        );
      }
    }
  );
}

Logging Inside Server Actions

Server Actions in Next.js 15 run in the Node.js runtime on the server, so AsyncLocalStorage works here too. However, there is a subtle constraint: the action is invoked by the React renderer, not a raw HTTP handler, so you must seed the store yourself at the top of the action.

A common pattern is a wrapper utility that seeds the store and provides the logger, keeping each action's body clean:

// lib/actions/with-logging.ts
import { headers } from 'next/headers';
import { runWithRequestContext } from '@/lib/logger/request-store';
import { logger } from '@/lib/logger/context-logger';

type ActionFn<TArgs extends unknown[], TResult> =
  (...args: TArgs) => Promise<TResult>;

export function withLogging<TArgs extends unknown[], TResult>(
  name: string,
  fn: ActionFn<TArgs, TResult>
): ActionFn<TArgs, TResult> {
  return async (...args) => {
    const headersList = await headers();
    const requestId =
      headersList.get('x-request-id') ?? crypto.randomUUID();

    return runWithRequestContext(
      { requestId, startTime: Date.now() },
      async () => {
        logger.info('action started: ' + name, { action: name });
        try {
          const result = await fn(...args);
          logger.info('action completed: ' + name);
          return result;
        } catch (err) {
          logger.error('action failed: ' + name, { err });
          throw err;
        }
      }
    );
  };
}

// Usage in a Server Action:
// export const submitOrder = withLogging('submitOrder', async (data) => { ... });

Sampling and Log Levels in Production

In a high-traffic Next.js app, logging every debug statement is expensive — both in compute time and in ingestion costs. Use two techniques together:

  • Level gating: check process.env.LOG_LEVEL and skip levels below the threshold. Production typically runs at info; debug is enabled only during incidents.
  • Sampling: for noisy info paths (e.g., health checks), log only a percentage of requests to reduce volume without losing signal entirely.

A standalone demonstration of the core sampling + level-gating logic:

// Standalone demo — runs without any framework
const LEVELS = ['debug', 'info', 'warn', 'error'];

const MIN_LEVEL = process.env['LOG_LEVEL'] || 'info';

function shouldLog(level) {
  return LEVELS.indexOf(level) >= LEVELS.indexOf(MIN_LEVEL);
}

function sample(rate) {
  // rate = 0.1 means log 10% of the time
  return Math.random() < rate;
}

function log(level, message, sampleRate = 1) {
  if (!shouldLog(level)) return;
  if (!sample(sampleRate)) return;
  console.log(JSON.stringify({ level, message, ts: new Date().toISOString() }));
}

// Simulated high-frequency health-check path — logs only ~10%
for (let i = 0; i < 20; i++) {
  log('info', '/api/health called', 0.1);
}

// Errors always log regardless of sample rate
log('error', 'Database connection failed');

Forwarding Logs to an External Sink

Serverless functions are ephemeral — stdout is only reliable if your platform captures it (Vercel does; bare AWS Lambda does not by default). A log drain / remote sink (Axiom, Better Stack, Datadog) guarantees durability.

From the Edge Runtime you can forward logs using fetch with waitUntil so the HTTP response returns immediately while the log POST happens in the background. From Node.js Route Handlers, use after() (Next.js 15) for the same non-blocking guarantee.

// lib/logger/axiom-drain.ts
// Edge-compatible log drain using fetch

const AXIOM_DATASET = process.env['AXIOM_DATASET'] ?? '';
const AXIOM_TOKEN   = process.env['AXIOM_API_TOKEN'] ?? '';

export async function sendToAxiom(
  entries: object[]
): Promise<void> {
  if (!AXIOM_DATASET || !AXIOM_TOKEN) return; // skip in dev

  await fetch(
    'https://api.axiom.co/v1/datasets/' + AXIOM_DATASET + '/ingest',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-ndjson',
        Authorization: 'Bearer ' + AXIOM_TOKEN,
      },
      // NDJSON: one JSON object per line
      body: entries.map((e) => JSON.stringify(e)).join('\n'),
    }
  );
}

// In a Middleware or Edge Route Handler:
// context.waitUntil(sendToAxiom([entry]));
//
// In a Node.js Route Handler (Next.js 15):
// import { after } from 'next/server';
// after(() => sendToAxiom([entry]));

Quick Check: Correlation Across Runtimes

You need every log line from a single user request — spanning Middleware (Edge), a Route Handler (Node.js), and a nested Server Action — to share the same requestId. Which approach is correct?

Lesson Recap: Structured Logging Across Server and Edge

In this lesson you built a complete, production-grade structured logging pipeline for Next.js 15:

  • Portable interface (Logger, LogContext) keeps business logic decoupled from the logging backend.
  • Runtime-specific implementations: createNodeLogger writes NDJSON to process.stdout; createEdgeLogger uses console.log — the only safe output channel in the Edge Runtime.
  • Correlation via AsyncLocalStorage: seed a store once (in a Route Handler or Server Action wrapper) and every nested await inherits requestId, userId, and elapsed time automatically.
  • Middleware as the request-ID origin: generate or honour an upstream x-request-id header in Middleware, forward it downstream, and seed the Node.js store on arrival.
  • Production discipline: gate log levels with LOG_LEVEL env var, sample noisy paths, serialise Error objects explicitly, and forward logs to a durable external sink with waitUntil or after() to avoid blocking responses.

These patterns together give you correlated, machine-parseable observability that survives cold starts, concurrent requests, and the constraints of both the Node.js and Edge runtimes.

Frequently asked questions

Is the “Structured Logging Across Server and Edge” lesson free?

Yes — the full text of “Structured Logging Across Server and Edge” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Structured Logging Across Server and Edge”?

Emit correlated, structured logs that survive serverless cold starts and Edge constraints. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Structured Logging Across Server and Edge” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. OpenTelemetry Tracing with instrumentation.ts
  2. Granular error.tsx and global-error Boundaries
  3. Structured Logging Across Server and Edge
  4. Capturing Server Action Failures and Telemetry
← Back to Next.js 15 Fullstack (App Router + Server Actions)