Getting started

Next.js

instrumentation.ts, middleware, route handlers, server actions and client components.

Next.js runs your code in three places — the Node.js server, the edge runtime (middleware) and the browser — and logit has one entry for the server side of all of it: @unhingged/logit/next. It imports only node:async_hooks, which the edge runtime provides, so it is safe in middleware.ts.

npm install @unhingged/logit

instrumentation.ts#

The register() hook runs once per server instance and is the place to configure the global log. Guard the configuration with process.env.NEXT_RUNTIME === 'nodejs', written literally in your file — Next inlines that check at build time only when it sees it there, and it is what keeps the Node-only module out of the edge bundle.

createOnRequestError() builds the onRequestError export: every error Next catches — in a render, a route handler, a server action, middleware — is logged with the route, the router kind and the request's method and path, then the logger is flushed (serverless instances can freeze right after).

// instrumentation.ts
import { createOnRequestError } from '@unhingged/logit/next';

export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { configure, consoleSink, fileSink, withProcessInfo } = await import('@unhingged/logit/node');
    configure({
      minimumLevel: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
      overrides: { next: 'warn' },
      enrichers: [withProcessInfo()],
      sinks: [consoleSink(), fileSink({ path: 'logs/app.log', rolling: 'day', retainedFileCount: 14 })],
    });
  }
}

export const onRequestError = createOnRequestError();

That branch configures the Node.js runtime only. Middleware runs on the edge runtime, where the defaults apply — info and above, JSON lines to the console — unless middleware.ts calls configure() at module top level itself. On Vercel, skip the file sink — the filesystem is ephemeral — and let consoleSink() write JSON to stdout, which the platform parses (level and message are recognised fields).

During React Server Components rendering Next may hand onRequestError a wrapped error; the digest that identifies the original is logged as Digest.

Route handlers#

withLogging() wraps a handler so that every request runs in an ambient scope — RequestId, RequestMethod, RequestPath on every event written inside — and ends with one completion event:

// app/api/orders/route.ts
import { log, withLogging, requestDiagnostics } from '@unhingged/logit/next';

export const GET = withLogging(async (request) => {
  const orders = await listOrders();
  requestDiagnostics.set('OrderCount', orders.length); // lands on the completion event
  log.debug('Listed {Count} orders', orders.length);   // carries RequestId automatically
  return Response.json(orders);
});
08:12:03.123 DBG Listed 12 orders RequestId=f0c1… RequestMethod=GET RequestPath=/api/orders
08:12:03.140 INF [http] HTTP GET /api/orders responded 200 in 17.2100 ms OrderCount=12 RequestId=f0c1…

A thrown error is logged at error with the request's facts and rethrown, so Next's own error handling still runs — which means a thrown route error is logged twice, by design: the completion event from withLogging (with StatusCode 500 and the timing) and the onRequestError event (with the route context and the digest). Filter one out if you only want one. redirect() and notFound() are recognised — they complete with 307 / 404, not as failures. The request id comes from x-request-id, then Vercel's x-vercel-id, else a UUID. Options are in Request logging.

Middleware#

The same wrapper, on the edge:

// middleware.ts
import { NextResponse } from 'next/server';
import { withLogging } from '@unhingged/logit/next';

export default withLogging(
  async (request) => {
    return NextResponse.next();
  },
  { ignore: (req) => req.path.startsWith('/_next') || req.path === '/favicon.ico', source: 'middleware' },
);

Middleware's completion event says what the middleware returned, not what the page eventually did — the two are logged separately. The edge runtime is not reached by instrumentation.ts's Node branch: with no configure() in middleware.ts the logger runs at info with a console sink that writes JSON lines through console.log (there is no document, so it is not a browser). To change that, call configure() at the top of middleware.ts.

Server actions#

A server action is a function; time it:

'use server';
import { log } from '@unhingged/logit/next';

export async function createOrder(input: OrderInput) {
  return log.timed('Action createOrder for {CustomerId}', async (op) => {
    const order = await orders.create(input);
    op.enrich('OrderId', order.id);
    return order;
  }, input.customerId);
}
// 08:12:03.123 INF Action createOrder for c_42 completed in 31.8 ms OrderId=1042

If the action throws, the event says abandoned, carries the error, and the error is rethrown. See Timed operations.

Server components#

Import log and write — server components run inside the request, so with a wrapped route they carry the context too. For pages (which have no handler to wrap) there is no completion event; log what matters and let onRequestError catch the failures.

Catching console output#

Next.js, and many dependencies, write to console.*. Route it through the logger so it comes out in the same shape, to the same sinks:

// inside register()'s nodejs branch
const { interceptConsole, log } = await import('@unhingged/logit/node');
interceptConsole(log, { source: 'console' });

console.log → info, console.debug → debug, console.warn → warn, console.error / console.trace → error; an Error among the arguments becomes the event's error. The function returned restores the console.

Client components#

The root entry runs in the browser. In a real browser (where document exists) the console sink hands the browser console real objects; for production, ship events to a route handler with the HTTP sink, which batches, retries and flushes on pagehide:

// lib/client-log.ts
'use client';
import { createLogger, consoleSink, httpSink } from '@unhingged/logit';

export const clientLog = createLogger({
  minimumLevel: 'info',
  sinks: [consoleSink(), httpSink({ url: '/api/logs', batchSize: 20, flushInterval: 5000 })],
});
// app/api/logs/route.ts
import { log, withLogging } from '@unhingged/logit/next';

export const POST = withLogging(async (request) => {
  const lines = (await request.text()).split('\n').filter(Boolean);
  const browser = log.forSource('browser');
  for (const line of lines) {
    const event = JSON.parse(line);
    browser.write(event.level, { ...event, serverReceived: true }, '{Message:l}', event.message);
  }
  return new Response(null, { status: 204 });
});

See Browser for what differs in the browser.

Vercel and serverless#

  • Write JSON to stdout: the default console sink does this when stdout is not a terminal.
  • Flush buffering sinks before the function returns: withLogging does not flush (too slow per request); createOnRequestError does. For an HTTP sink on serverless, keep flushInterval short and call await log.flush() at the end of long handlers.
  • Set LOGIT_LEVEL in the project's environment variables to change the minimum level without a deploy.

More in Production.