You deploy your Next.js app, something breaks at 2am, you open the hosting dashboard, and you are staring at a wall of grey text that says fetching user and then, forty lines later, done. Which user? How long did it take? Was that even from the request that failed? 😅
That is the moment most of us realise console.log was never a logging system. It was a debugging tool we accidentally shipped to production.
In this post we'll swap it for Pino, a logging library that writes one JSON object per line instead of loose strings. We'll install it, make the output readable while we develop, pull it into a single shared logger module, and thread one id through an entire workflow so a request is easy to follow. We're using pino 10.3.1, pino-pretty 13.1.3 and Next.js 16.3.1, and the outputs below are all copied straight from a real terminal.
Where console.log runs out#
console.log is fine right up until someone else has to read what it wrote. Then a few problems show up at once.
The levels don't actually do anything. You might reasonably point out that console already has debug, info, warn and error, and it does. But in Node, those are mostly just different methods. There is no log level threshold you can configure. They all print.
That means a message you only needed while debugging and a message that means "payments are down" can both end up in production. The only way to silence the first one is to remove the line or add your own condition.
Node does make one distinction: warn and error write to stderr, while debug, info and log write to stdout. That's two output streams, not a useful logging hierarchy.
There are also no timestamps on regular log entries, so you can't tell whether two lines happened a millisecond apart or ten minutes apart. console.time() and console.timeEnd() can measure how long a particular operation takes, but that's different from automatically recording when each log entry happened.
And there is no structure. Something like console.log('user', id, 'took', ms) produces a sentence. A sentence is fine for a human reading a terminal, but it isn't very useful when you need to filter, group, or search your logs.
And that's the part that really hurts in production: searchability.
Your hosting provider gives you a search box. You type the user ID you're investigating and expect to find every related log entry. But your ID is buried inside a string with spaces and other text around it. There's no field saying userId that the logging system can reliably filter on.
If you'd like a nudge to stop reaching for it, the pre-commit hook setup in our Husky post includes a check that blocks any commit containing a console.log, which pairs nicely with actually having something better to replace it with.
What Pino gives you instead#
Pino writes one JSON object per line. That format has a name, NDJSON (newline-delimited JSON), and most log platforms know how to parse it out of the box.
Each line carries a numeric level, a timestamp, the process ID, the hostname and your message. Anything extra you attach becomes a real field, not part of a sentence. So "find me every warning for user 42 in the last hour" becomes a filter rather than a grep that you have to squint at.
But isn't building JSON slower than printing a string, you might wonder? It is a fair question, and the answer is the reason Pino exists. Most logging libraries format a message: they take a template, interpolate values into it, apply colours, and pad columns, all before anything gets written. Pino serialises instead. It turns your object into a JSON string with as little work as possible and pushes it to stdout, leaving the prettifying to a separate process that only runs when you ask for it. Less work per line means logging costs you less inside a hot request path. ⚡
Installing Pino and writing the first log#
Let's install it. In your Next.js project, run:
npm install pinoThat is the only package you need for production. We'll add pino-pretty shortly, but that one belongs in dev dependencies, so we'll keep them separate on purpose.
If you're on TypeScript, and these days most Next.js projects are, you do not need a types package. Pino ships its own type definitions inside the package. @types/pino does exist on npm, but it is only a stub whose description literally says that pino provides its own definitions, so installing it gains you nothing. Every snippet below typechecks under strict as it stands.
Now the smallest possible logger. The question that trips people up here is where do I actually put this, so let's be concrete: it has to be a file that runs on the server. The quickest way to see a real line of output is a route handler, so create src/app/api/health/route.ts:
import pino from 'pino';
const log = pino();
export async function GET() {
log.info('health check requested');
return Response.json({ ok: true });
}Start your app with npm run dev, then visit http://localhost:3000/api/health.
Now look in the terminal where npm run dev is running, not the browser console. This catches nearly everyone once: log.info runs on the server, so its output goes to the server's stdout. Nothing appears in your browser devtools, and that is correct behaviour rather than a broken setup.
We'll move this logger into its own module in a moment, so treat the route handler as a place to see it work rather than where it lives permanently.
That is the whole setup. pino() with no arguments creates a logger with all the defaults, and log.info(...) writes a line at the info level. Here is what came out in the terminal, with the pid and hostname values swapped for neutral ones since yours will differ anyway:
{"level":30,"time":1787040941389,"pid":71541,"hostname":"my-laptop.local","msg":"health check requested"}Let's read that line field by field, because every log you write from now on has this shape.
levelis30, which is Pino's numeric code forinfo.timeis a Unix timestamp in milliseconds. It is not pretty, but it is unambiguous, and every log platform converts it for you.pidandhostnametell you which copy of your app wrote the line.pidis the process ID, a number the operating system hands to every running program, andhostnameis the machine it ran on. While you're developing there is only one copy, so both look like noise. They start earning their place the moment several copies run at the same time, whether that's a cluster of processes on one server or containers spread across several, because then they are the only thing telling you which copy produced the entry you're staring at.msgis the string you passed in.
And if your reaction to "level":30 is that a number is a lot less readable than the word info, you are completely right. The same goes for a timestamp like 1787040941389, which no human reads at a glance. Nobody stares at raw JSON like this while developing, and you won't have to either. We'll fix exactly that in a couple of minutes with a tool called pino-pretty, so let the ugly version stand for now. It is ugly on purpose, because this is the shape a log platform wants to receive, not the shape you read.
The log levels, and which one to reach for#
Pino ships six levels, each with a number attached:
| Method | Value | Reach for it when |
|---|---|---|
log.trace() |
10 | You are following execution step by step, temporarily |
log.debug() |
20 | Detail that helps you locally but would be noise in production |
log.info() |
30 | Something normal and noteworthy happened |
log.warn() |
40 | Something is wrong but the request still succeeded |
log.error() |
50 | An operation failed and someone should know |
log.fatal() |
60 | The process cannot continue |
The default level is info, and this is the part beginners get caught by. Anything below 30 is silently dropped.
Let's prove that in the route handler we already have, by adding a debug line above the info one:
export async function GET() {
log.debug('starting health check');
log.info('health check requested');
return Response.json({ ok: true });
}Refresh /api/health and only one line comes back:
{"level":30,"time":1787040941389,"pid":71541,"hostname":"my-laptop.local","msg":"health check requested"}The debug call ran perfectly well. It just went nowhere. Not an error, not a warning. Nothing. Your logger is working exactly as designed, because you asked it for a level it was told to ignore. We'll turn that dial with an environment variable further down, and this line will come back without you editing the file again.
Adding context: the object-first argument#
A health check is a thin example, because nothing about it ever varies. Every request logs the same sentence. So let's move somewhere the values actually change and keep building there: a route that looks up an order by id, at src/app/api/orders/route.ts.
The single habit that makes Pino worth the install is passing an object before the message:
import pino from 'pino';
const log = pino();
export async function GET(request: Request) {
const orderId = new URL(request.url).searchParams.get('id') ?? 'unknown';
const startedAt = Date.now();
// stand-in for whatever your real lookup does
await new Promise((resolve) => setTimeout(resolve, 120));
const durationMs = Date.now() - startedAt;
log.info({ orderId, durationMs }, 'order loaded');
return Response.json({ id: orderId });
}Notice the order: object first, string second. Visit /api/orders?id=8842 and that object gets merged into the top level of the JSON line:
{"level":30,"time":1787042421780,"pid":80250,"hostname":"my-laptop.local","orderId":"8842","durationMs":120,"msg":"order loaded"}orderId and durationMs are now fields. Not text buried inside msg, but real keys with real values. That means your log platform can offer you "filter where orderId = 8842" or "show me every request over 500ms", and it means each value keeps its type, so 120 is a number you can compare against rather than the characters 1, 2 and 0.
Compare it with what most of us write out of habit:
// Don't do this
log.info(`order ${orderId} loaded in ${durationMs}ms`);That produces "msg":"order 8842 loaded in 120ms". To find the slow ones you now have to match a substring, and the moment you reword the message, every saved search and every alert built on top of it quietly stops matching. Keep the message a constant string and put the variable parts in the object. That one rule is most of the benefit.
Making it readable while you develop#
Structured JSON is superb for machines and rough on human eyes. That's what pino-pretty is for.
npm install --save-dev pino-prettyThere are two ways to wire it up, and they are not equivalent.
The first way is piping. Leave your logger untouched and send the output through the pino-pretty binary:
next dev | npx pino-prettyWhich turns those JSON lines into this:
[11:50:21.717] INFO (82862): order loaded
orderId: "8842"
durationMs: 120Readable timestamp, the level spelled out and colour-coded, and your extra fields indented underneath. Your app never knows any of this happened. It is still writing plain JSON to stdout, and a separate process is doing the formatting.
The second way is a transport, configured on the logger itself. That means passing an option to the pino() call in your route handler for now, though in the next section we'll move this into a single shared logger file where it really belongs:
const log = pino({
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});The transport option tells Pino to hand each line to a worker thread running pino-pretty. The isDev guard is doing real work here: in production you want raw JSON, because your host is going to parse it.
So which one? Here's the tradeoff:
| Piping through the CLI | Transport in the config | |
|---|---|---|
| Setup | Change your dev script |
Change your logger file |
| App code | Untouched | Knows about formatting |
| Covers Next.js's own output | Yes | No, only your logger |
| Extra thread | No | Yes, one worker |
| Risk of bundler trouble | None | Some, it runs in a worker thread |
Piping is the safer default, and it has a bonus: it prettifies everything on stdout, including Next.js's own startup lines. The transport wins when you can't control how the app is started, like a hosted dev environment or a script someone else owns.
For what it's worth, I went with the piping. This was my first time setting Pino up, and I did not fancy walking into surprises on day one.
And we are not finished with this setup. What we have works, but the structure is still rough around the edges: the logger is created inside a route file, and every route that wants to log has to build its own. We'll tidy that up as we go, so treat what follows as improvements to this same setup rather than a fresh start.
One logger module, not one per file#
Your instinct will be to call pino() wherever you need to log. Resist it. 🙂
A Pino logger holds a write stream, and if a transport is configured it holds a worker thread too. Creating a fresh one inside a route handler means building all of that on every single request, then throwing it away. And if a transport is involved, that's a thread spawned per request, which is a genuinely expensive way to write a line of text.
So we create it once. Make a file at src/lib/logger.ts, which is where it will live from now on:
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { env: process.env.NODE_ENV },
});You don't have to annotate the return type. Pino infers it, and hovering logger in your editor shows Logger<never, boolean>, which is the type you'd import as Logger from pino if you ever need to write it down (passing the logger into a helper function, for instance).
Two options in there. level reads from an environment variable with a sensible fallback, which we'll come back to. base replaces the default pid and hostname fields with whatever you give it. Here we're tagging every line with the environment, so staging and production logs are told apart at a glance.
Then everywhere else you import it:
import { logger } from '@/lib/logger';
export async function GET(request: Request) {
const orderId = new URL(request.url).searchParams.get('id') ?? 'unknown';
logger.info({ orderId }, 'order loaded');
return Response.json({ id: orderId });
}Child loggers, when you want context to stick#
Once you have the singleton, you can branch off it. A child logger carries fields automatically so you stop repeating yourself:
const requestLog = logger.child({ requestId });
requestLog.info('started');
requestLog.info({ ms: 42 }, 'finished');Both lines carry requestId without you passing it twice. A child is cheap, because it shares the parent's stream rather than opening a new one, so this is the right way to add per-request context, and the wrong way is a fresh pino() call.
That per-request use is the one most people reach for first. There is a second one that pays off just as much, though, and it is worth knowing about early: labelling whole areas of your app. If your codebase has invoicing, payments and ordering, give each of them a child logger and create it once, next to the singleton:
// src/lib/logger.ts
export const paymentsLog = logger.child({ module: 'payments' });
export const ordersLog = logger.child({ module: 'orders' });Every line those write now carries its label, without a single extra argument at the call site:
{"level":30,...,"module":"payments","msg":"charge captured"}
{"level":40,...,"module":"orders","orderId":"8842","msg":"stock low"}Which turns "show me everything payments did this morning" into a filter on one field, rather than you trying to remember which messages the payments code happens to use. It also survives refactors, because the label lives in the logger rather than in the wording of each message.
The two uses stack, which is where this gets genuinely useful. Branch a per-request child off a module child and the line carries both:
const requestLog = ordersLog.child({ requestId });
requestLog.info({ ms: 42 }, 'finished');{"level":30,...,"module":"orders","requestId":"req-77","ms":42,"msg":"finished"}And there is one more thing a child can carry: its own level, set independently of its parent. That is what the second argument to child() is for, an options object where level is one of the things you can set:
export const paymentsLog = logger.child({ module: 'payments' }, { level: 'debug' });Leave that second argument off, as we did earlier, and the child simply inherits whatever the parent is set to.
With the root logger sitting at info, that one line means payments emits its debug output while every other area of the app stays quiet. When you are chasing a bug in one subsystem in production, that is the difference between the detail you need and a firehose of everything at once.
Where logging actually runs in a Next.js app#
This one catches people who put a logger.info() in a component and then hunt for it in the browser console. Next.js runs your code in more than one place, and Pino is a Node.js library.
| Where your code runs | Does Pino work? | What to know |
|---|---|---|
| Server Components | Yes | Full Pino, output goes to your server terminal or host logs |
Route Handlers (app/api/*) |
Yes | The natural home for request logging |
| Server Actions | Yes | Great place to log the mutation and its outcome |
middleware.ts on Edge |
No | The Edge runtime lacks the Node APIs Pino needs |
| Client Components | No | Runs in the browser; there is no stdout to write to |
The rule that keeps you out of trouble: import the logger only from files that are server-only. If a client component imports your logger module, the bundler follows that import and you'll get a build error about a Node module in browser code, which is confusing until you realise it's the import chain talking, not the logging call.
For middleware on the Edge runtime, console.log genuinely is the tool you have. It's a small enough surface that it rarely matters.
Log levels per environment with LOG_LEVEL#
Remember our logger reading process.env.LOG_LEVEL? Now we can use it.
Add it to .env.local for your own machine:
LOG_LEVEL=debugWith that set, every log.debug() you sprinkled around starts printing, and when you unset it, they all go quiet again without you touching a line of code. That is the whole point of levels: debug calls are permanent instrumentation you leave in the codebase, not temporary lines you delete before committing.
A setup that works well in practice:
- local development:
debug, so you see the detail while building - staging:
debugorinfo, depending on how noisy things get - production:
info, orwarnon a very high-traffic service
Set it in your hosting provider's environment variable settings rather than committing it, the same way you handle everything else in there. Our post on keeping secrets out of your repo with environment variables covers the .env file conventions and what belongs in each one. LOG_LEVEL isn't a secret, but it lives alongside the ones that are.
One thing worth knowing: the level is read once, when the logger is created. Changing the variable means restarting the process. You can also change it at runtime with logger.level = 'debug', which is handy behind an admin-only route on a service you can't restart casually.
Frequently Asked Questions#
Should I log the error and throw it as well?#
Usually, no. Pick one place to log it.
A useful rule is:
Throw where it fails, log where you handle it.
Here is what goes wrong when you do both.
Say saveOrder() catches a database error, logs it, and then throws it. The code that called it catches the error and logs it again:
async function saveOrder(order) {
try {
await db.orders.create(order);
} catch (err) {
log.error({ err }, 'failed to save order');
throw err;
}
}
async function checkout(order) {
try {
await saveOrder(order);
} catch (err) {
log.error({ err }, 'order could not be completed');
}
}The result is two log entries for one failure:
{"level":50,...,"msg":"failed to save order"}
{"level":50,...,"msg":"order could not be completed"}Now when you are looking at your logs, you have to figure out whether those are two separate problems or the same problem being reported twice.
Usually, the second message is the useful one.
saveOrder() only knows that saving the order failed. The code handling the checkout knows what that failure means to the application: the order could not be completed.
That's the message you actually want to see when you are investigating a problem.
So saveOrder() should throw the error and let its caller decide what to do with it:
async function saveOrder(order) {
try {
await db.orders.create(order);
} catch (err) {
throw new OrderSaveError({ cause: err });
}
}The caller can then log it once:
try {
await saveOrder(order);
} catch (err) {
log.error({ err }, 'order could not be completed');
}This also keeps your lower-level functions reusable. The same database failure might need to be retried in one place, turned into an error response somewhere else, or simply recorded and ignored in a background job.
The function that caused the error usually doesn't know which one is appropriate.
Why don't my log.debug() lines show up?#
Because the default level is info, and anything below it is dropped without a word. debug is 20, info is 30, so debug calls run and go nowhere.
Set LOG_LEVEL=debug in .env.local and restart. The level is read when the logger is created, so a running process will not pick up the change on its own.
How do I keep passwords and tokens out of my logs?#
Use the redact option, which takes the paths you never want written:
export const logger = pino({
redact: ['password', 'req.headers.authorization', '*.token'],
});Anything matching comes out as "[Redacted]" while the rest of the object is untouched, and the wildcard form catches the same key wherever it is nested. This is worth setting up on day one, because structured logging makes it far easier to accidentally log a whole user object than console.log ever did.
Where to go next#
At this point you have structured logs with real levels, one logger module the whole app shares, readable output while you develop, and a correlation id that follows a request wherever it goes.
The obvious next question is where those JSON lines should end up once they leave your server, since a terminal that scrolls away is not much of an archive. Shipping logs to a hosted destination is its own topic with its own set of gotchas, and it's the subject of a follow-up post. 🚀
