Logging
Server-side structured logging with Pino: pretty output in dev, JSON with PII redaction in production, 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
lib/logger.ts exports one shared logger, built one of two ways based on NODE_ENV:
- Development runs the output through
pino-pretty. You get colored, readable lines in your terminal, with a timestamp and the noisypid/hostnamefields removed. No redaction. - Production prints raw JSON, one log per line. This is the format log services (Datadog, Logtail, Axiom, and the like) read, and it has redaction applied.
Both run at level info. Dev uses Pino's default level; prod sets level: "info" explicitly. So trace and debug calls cost nothing and produce no output unless you raise the level.
Structured logging
"Structured" means each log is a set of named fields, not one flat string. Pass a context object first and the message second:
import { logger } from "@/lib/logger";
logger.info("User signed in");
logger.error({ userId, err }, "Failed to update profile");In production that becomes JSON like {"level":50,"userId":"...","msg":"Failed to update profile"}. Each field is its own column you can search and filter on, so you can pull every error for one userId. Glue those values into the message string instead and you lose that, so prefer the context object.
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"]. This tells Next to leave both packages alone instead of bundling them. Pino loads some modules at runtime in a way Next's bundler can't follow, so bundling breaks it. Externalizing keeps them as normal node_modules imports. Remove this entry and logging fails at build or runtime, so keep it.
Log levels
| Method | Value | Use for |
|---|---|---|
logger.trace() | 10 | Very granular tracing (off by default) |
logger.debug() | 20 | Diagnostic detail (off by default) |
logger.info() | 30 | Normal events like sign-in or webhook received |
logger.warn() | 40 | Unexpected but recoverable, like rate limits or missing optional config |
logger.error() | 50 | Genuine failures needing attention |
logger.fatal() | 60 | Unrecoverable, process should exit |
To see debug/trace while developing, set logger.level = "debug" after import.
The after() pattern in Server Actions
Logger calls inside Server Actions are wrapped in after() from next/server. after() runs its callback after the response is sent to the user, so logging never slows down the action's return value. app/actions/turnstile.ts is the reference:
} catch (error) {
after(() => {
logger.error(
{ event: "turnstile_verification_error", err: error },
"Turnstile verification failed",
);
});
return false;
}The other action files in app/actions/ (contact.ts, settings.ts, files.ts, user.ts) use the same pattern. Other server modules like lib/auth.ts call logger directly, without after().
If you want a group of related calls to share the same fields, derive a child logger with Pino's child() method: const log = logger.child({ requestId }). Every call on log then includes requestId automatically, so you don't repeat it. The template doesn't use this anywhere, but it's there when you need it.
PII redaction
Logs often pass through other systems and people. You don't want secrets or personal data (PII, personally identifiable information, like a user's email) sitting in them. Redaction means Pino looks for named fields and replaces their values with [REDACTED] before the log is written. The field name still shows; the value is hidden.
This runs in production only. The redact.paths list in lib/logger.ts covers credentials and email PII:
| 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) |
So passing a user or request object that holds these fields is safe in production; the sensitive values are hidden. In development there's no redaction, so you see the real values in your terminal while debugging.
Add a path: edit the redact.paths array in lib/logger.ts. Pino matches by field path and supports dot notation (nested) and * (wildcard):
"creditCard.number", // a nested field
"*.apiKey", // apiKey on any top-level objectShipping logs to a provider
The production logger writes JSON to stdout (standard output), one log per line. Every log service accepts this format, so the common case needs no code change. Platforms like Railway, Render, and Fly.io collect stdout for you, so point their log drain at your destination. For self-hosted Docker, route the container's stdout where you want it.
To send logs straight from the app instead, replace the production branch in lib/logger.ts with a Pino transport (for example pino/file, @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 entry that keeps Pino working |
app/actions/*.ts | Usage sites showing the after() pattern |