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 noisypid/hostnamefields removed. - Everything else (production,
test, or an unsetNODE_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:
| Path | Matches |
|---|---|
password, token, secret, authorization, cookie, auth, jwt | Top-level credential fields |
email, oldEmail, newEmail | Top-level email fields |
*.email | An 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 objectShipping 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
| File | Role |
|---|---|
lib/logger.ts | The logger instance: dev/prod branch, level, redaction paths |
next.config.ts | serverExternalPackages, where a Pino transport gets registered |
app/actions/*.ts, lib/server/turnstile.ts | Usage sites showing the after() pattern |
lib/auth.ts | The busiest caller: sign-in, sign-up, and Stripe subscription events |