Next Starter Logo
Reference

Logging

Server-side structured logging with Pino: pretty output in dev, JSON in production, PII redaction in both, and a non-blocking after() pattern in Server Actions.

Next Starter logs on the server with Pino, a fast logger that adds almost no overhead. One configured instance lives in lib/logger.ts and is exported as logger. Import it anywhere on the server and call it.

How it works

The only branch in lib/logger.ts is process.env.NODE_ENV === "development":

  • Development runs the output through pino-pretty. You get colored, readable lines in your terminal, with a timestamp and the noisy pid/hostname fields removed.
  • Everything else (production, test, or an unset NODE_ENV) prints raw JSON, one log per line. This is the format log services (Datadog, Logtail, Axiom, and the like) read.

Both branches share the same redact object, so redaction applies in development too.

The level is info on both sides. Dev gets there through Pino's default and prod sets level: "info" explicitly. So trace and debug calls are no-ops and produce no output unless you raise the level.

Structured logging

Pass a context object first and the message second. Nearly every call site in the template carries an event key naming what happened:

import { logger } from "@/lib/logger";

logger.info({ event: "user_signed_in", userId, email }, "User signed in");
logger.error({ event: "create_user_failed", email, err: error }, "Failed to create user");

In production the first line becomes JSON like {"level":30,"event":"user_signed_in","userId":"...","email":"[REDACTED]","msg":"User signed in"}. Every field is queryable on its own, so you can pull each create_user_failed or every line for one userId. Glue those values into the message string and you lose that.

Server-only. Pino imports Node built-ins. Importing logger into a Client Component breaks the browser build.

Why Pino is in serverExternalPackages

next.config.ts lists serverExternalPackages: ["pino", "pino-pretty"] so Next doesn't bundle either one. Pino resolves its transport worker by file path at runtime, which bundlers don't follow without extra configuration. Next already ships both names on its own don't-bundle list and treats your array as an addition to it, so these two entries are belt and braces. The array earns its keep the day you swap in a Pino transport, which does need registering.

Log levels

Pino's six levels map to the numbers that appear in the JSON level field: trace 10, debug 20, info 30, warn 40, error 50, fatal 60. While developing, set logger.level = "debug" after import to see diagnostics, or "trace" for everything.

The after() pattern in Server Actions

Server Actions wrap their logger calls in after() from next/server. after() runs its callback once the response has gone to the user, so those calls never slow down the action's return value. lib/server/turnstile.ts, called from the contact action, is the reference:

} catch (error) {
  after(() => {
    logger.error(
      { event: "turnstile_verification_error", err: error },
      "Turnstile verification failed",
    );
  });
  return false;
}

The actions that log follow suit: contact.ts, settings.ts, files.ts, and user.ts, as does the settings Server Component in app/dashboard/settings/page.tsx. lib/auth.ts and lib/email/index.tsx call logger directly, without after().

PII redaction

Redaction replaces the value of a named field with [REDACTED] before the line is written, so the listed credential fields and user emails are stripped before they reach whatever system stores your logs. Values nested outside those paths, such as user.password or the args array Better Auth logs, are not covered. The same redact object goes to both the pretty and the JSON branch, which is why it applies whatever NODE_ENV is. The redact.paths list in lib/logger.ts covers exactly this much:

PathMatches
password, token, secret, authorization, cookie, auth, jwtTop-level credential fields
email, oldEmail, newEmailTop-level email fields
*.emailAn email field on any one-level-nested object (e.g. user.email)

Paths match literally, so that list and nothing else. The wildcard catches user.email. It misses user.password and request.headers.authorization, because nothing matches a credential nested that deep. Development redacts too, so an email you log shows as [REDACTED] in your terminal as well.

One caller to watch: lib/auth.ts forwards Better Auth's own logs through as { source: "better-auth", args }. Nothing inside args is redacted, and those lines carry no event key. That file pins Better Auth to level: "warn", so only its warnings and errors reach you.

To cover a field the list misses, add it to redact.paths. Dot notation reaches a nested field and * stands in for one level:

"creditCard.number",  // a nested field
"*.apiKey",           // apiKey on any top-level object

Shipping logs to a provider

The production logger writes one JSON object per line to stdout, the format log services ingest, so the common case needs no code change. Railway, Render and Fly.io collect stdout for you. Point their log drain at your destination. Self-hosted Docker works the same way: route the container's stdout wherever you want it.

To ship from inside the app instead, replace the production branch in lib/logger.ts with a Pino transport such as @logtail/pino or pino-datadog-transport. Keep the redact block when you do, so PII stays protected.

Where it lives

FileRole
lib/logger.tsThe logger instance: dev/prod branch, level, redaction paths
next.config.tsserverExternalPackages, where a Pino transport gets registered
app/actions/*.ts, lib/server/turnstile.tsUsage sites showing the after() pattern
lib/auth.tsThe busiest caller: sign-in, sign-up, and Stripe subscription events

On this page