Here is the scenario every production app eventually produces. A user emails you: checkout did not work, money may or may not have left their account. You open your logs and search for "error". Four hundred hits. Some are a bot probing /wp-login.php. Some are a fetch that failed once and succeeded on retry. Some say just "FAILED" with no indication of what failed or for whom. Somewhere in those four hundred lines is the one request that matters, and you have no way to ask for it.
console.log("here3") fails this test, but not because it is lazy. It fails because it answers exactly one question — does execution reach this line? — and that is a development question. You had it while writing the code, you answered it, and the answer stopped being useful the moment you deployed. Production asks different questions: which requests failed, for which user, after which step, how often this week. A stream of prose cannot answer questions like that. A dataset can.
That is the entire idea behind structured logging. Every log line is a JSON object with the same consistent fields — a timestamp, a level, an event name, and whatever context identifies this particular occurrence. You write for machines first, because in production you will never read the raw stream. You will read the results of queries, and queries need structure to grab onto.
console.log(`User ${email} logged in`);
// -> User mike@example.com logged in
log.info("auth.login.succeeded", { userId: "usr_8f3k1x", method: "credentials" });
// -> {"ts":"2026-06-10T01:57:12.430Z","level":"info","event":"auth.login.succeeded","userId":"usr_8f3k1x","method":"credentials"}The second version looks like more effort for the same information. It is not the same information. The first line can only be found by guessing the exact wording and writing a regex against it. The second can be queried: every login this user ever made, every credentials login that failed this week, login volume by method. Notice the event name too — auth.login.succeeded is a stable, namespaced identifier, not a sentence. The moment you write prose into the event name, you are back to grep. Keep the name fixed and put everything variable in the fields.
Most codebases use log levels as a mood ring — error when the developer felt alarmed, info otherwise. Levels only become useful when each one carries an operational promise, a statement about what a human should do when they see it.
- error — someone should be notified now. Something broke, a user felt it, and it will not fix itself. If you would not want a 2am alert for it, it is not an error.
- warn — investigate this week. Something suspicious happened but the system survived: a retry that succeeded on the second attempt, an upstream API responding slowly, a user brushing against a rate limit.
- info — business events you would want in an audit trail: a signup, a payment, a sent email, a cancelled subscription. Things that happened, recorded because they matter, not because they might be broken.
- debug — for you, while developing. Off in production, always.
The hard part is not the definitions, it is the discipline. Entropy pulls everything toward info. Each "fetched user profile" logged at info seems harmless on its own, but collectively they bury the signal you built the system to surface. The test for every log call is simple: who acts on this, and when? If the answer is "nobody, probably", the line belongs at debug or nowhere. A levels scheme where everything is info is no scheme at all — you are running grep with extra steps. The wrapper we will build in a moment reads LOG_LEVEL from the environment, so you run debug locally and info in production without touching code.
On a serverless platform, your logs are interleaved by default. Ten users hit your API at once, ten function invocations write lines into the same stream, and the failed checkout you are hunting is shredded across them. A single user action might touch your middleware, a route handler, two service functions, and an external API — five log lines that belong together, with nothing tying them to each other. Reconstructing the story by matching timestamps by eye is exactly the 2am activity this article exists to abolish.
The fix is the request ID pattern: generate a unique ID at the front door, attach it to every log line the request produces. In Next.js, the front door is middleware.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const requestId = crypto.randomUUID();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-request-id", requestId);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("x-request-id", requestId);
return response;
}
export const config = { matcher: ["/api/:path*"] };Two things happen here. The ID is written into the request headers, so every server-side function downstream can read it. And it is echoed back on the response, which pays off in a way that surprises people: when your error responses include the request ID, the user's bug report arrives carrying the exact key to their failure. "It said error f0b7-something" is a one-query diagnosis.
import { headers } from "next/headers";
import { log } from "@/lib/logger";
import { createCheckoutSession } from "@/lib/payments";
export async function POST(request: Request) {
const requestId = (await headers()).get("x-request-id") ?? "unknown";
log.info("checkout.started", { requestId });
try {
const session = await createCheckoutSession(request);
log.info("checkout.session_created", { requestId, sessionId: session.id });
return Response.json({ url: session.url });
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
log.error("checkout.failed", { requestId, message });
return Response.json({ error: "Checkout failed", requestId }, { status: 500 });
}
}Yes, you are passing requestId by hand. At this scale, explicit beats clever — you can see exactly what every log line will contain. When the threading starts to genuinely hurt, that is one of the signals to graduate to a heavier logger, which we will get to.
Signal comes from logging a small number of high-value events well, not from logging everything. For a typical production app, four categories earn their place.
- Authentication events: login success and failure (with a reason category, never the submitted credentials), signups, password-reset requests, MFA failures. When an account gets taken over, this is your only record of how.
- Payment events: checkout started, webhook received and verified, subscription created or cancelled. Any question involving money deserves an audit trail you did not have to reconstruct from Stripe's dashboard alone.
- External API failures: which service, what status code, how long it took. At 2am, "is it us or is it them" is the first question, and this is how you answer it in seconds.
- Rate-limit triggers: who hit the wall and on which route. This is your early warning for both an attack and your own buggy client stuck in a retry loop.
The never-log list matters more, because these mistakes are not undone by a deploy. Logs get shipped to third parties, cached in dashboards, and held in retention long after the request died.
- Passwords — including failed login attempts. A failed attempt is very often the user's real password with one typo in it.
- Tokens of any kind: session cookies, JWTs, API keys, password-reset tokens. A leaked log with a valid token in it is an authentication bypass.
- Full email addresses, names, physical addresses. Log a userId and join against your database on the rare occasion you need the person behind it.
- Entire request bodies "just in case". They contain all of the above.
If you operate from the EU, this stops being hygiene and becomes law. Under GDPR, log lines containing personal data are personal-data processing, the same as your database — they need a retention policy, they are subject to access and deletion requests, and an email address sitting in a log stream you forward to a third-party service is a data transfer you are accountable for. The cheapest compliance strategy ever devised is to not put personal data in your logs in the first place. The habit worth building: before every log call, ask whether you would be comfortable seeing that exact line quoted in a breach disclosure.
tip
Do not rely on discipline alone — discipline fails at 2am too. Add a denylist to your logger that strips keys like password, token, authorization, and email from the context object before serializing. Ten lines of code, and an entire category of incident becomes structurally impossible.
The reflex at this point is npm install winston. Resist it for a moment and look at what winston actually solves: multiple transports, file rotation, pluggable formats, exception handlers. Those are the problems of a long-running server writing to its own disk. On Vercel, your only transport is stdout, the platform collects it for you, and the format you want is JSON. Installing winston here is configuring a freight train to deliver a letter. Everything this article has described fits in a file this size:
type Level = "debug" | "info" | "warn" | "error";
type Context = Record<string, unknown>;
const ORDER: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
const MIN = ORDER[(process.env.LOG_LEVEL as Level) ?? "info"] ?? ORDER.info;
function emit(level: Level, event: string, context: Context = {}) {
if (ORDER[level] < MIN) return;
const entry = JSON.stringify({
ts: new Date().toISOString(),
level,
event,
...context,
});
const write =
level === "error" ? console.error : level === "warn" ? console.warn : console.log;
write(entry);
}
export const log = {
debug: (event: string, context?: Context) => emit("debug", event, context),
info: (event: string, context?: Context) => emit("info", event, context),
warn: (event: string, context?: Context) => emit("warn", event, context),
error: (event: string, context?: Context) => emit("error", event, context),
};A few deliberate choices are hiding in there. It has zero dependencies and uses nothing beyond console and JSON, which means it runs unmodified in the Edge runtime — your middleware can use the same logger as your route handlers. LOG_LEVEL filters noise per environment. And routing errors through console.error rather than console.log is not cosmetic: Vercel uses the console method to mark a line's severity, so your errors are filterable in the dashboard before any other tooling exists.
You outgrow this wrapper when one of three things happens: you move to a long-running, high-throughput Node server where JSON serialization cost actually shows up in profiles; you want automated redaction by key path instead of a hand-rolled denylist; or threading requestId through every call becomes genuinely painful and you want child loggers that carry context automatically. That is when pino earns its place — it is fast, structured by default, and its redact option does the PII scrubbing for you. The migration is mechanical precisely because the wrapper taught your codebase the same shape pino expects. The structure is the asset. The library is an implementation detail.
On Vercel, function logs are ephemeral. Retention is measured in hours on the hobby plan and days on paid plans — not weeks. The user who emails you on Monday about Friday's failed checkout is describing logs that no longer exist. Structured logging into a void is still a void. You need the lines to land somewhere with retention and a query box: a log drain (available on paid plans) that forwards every stdout line to a service like Axiom or Better Stack, where your JSON fields become searchable columns and a query like event:checkout.failed over the last thirty days takes two seconds.
The other half of the destination question is error tracking, and the division of labour matters: logs are the story, the error tracker is the alarm. Sentry should not replace your logs and your logs should not replace Sentry. When an exception reaches a boundary — like the ones described in the error boundaries guide, attach the request ID as a tag when you capture it. Now the alert tells you something broke, and one query in your log drain replays the user's entire journey up to the failure. Deciding which of those errors should actually wake you, and which can wait for morning, is its own discipline — that alerting layer is covered in the observability guide for solo SaaS operators.
warning
Beware the log-and-rethrow trap. A catch block that logs the error and throws it again means the layer above logs it too, and so does the error tracker — one failure, three reports, and at 2am you will chase them as three different bugs. The rule: log where you handle. If you are rethrowing, you are not handling — add context by wrapping instead (new Error("checkout failed", { cause: err })) and let the boundary that actually deals with it report once.
Do not retrofit your whole codebase this weekend — that is how logging projects die. The version that ships tonight: drop the logger into lib/logger.ts, add the request-ID middleware, and convert only your auth and payment paths, because those are where 2am happens. That is roughly five events: login failed, signup completed, checkout started, webhook verification failed, external API error. Set up the drain before you need it, since the incident that proves you needed retention is by definition the one you no longer have logs for.
Treat this as one habit inside a larger set of defaults — structured logging is a single line item on the production-grade code checklist this series is built around, next to validation, error handling, and rate limiting. None of them is glamorous. All of them are the difference between an app that works in the demo and one you can operate.
The payoff is concrete and arrives on a schedule you do not control. Some night, something will break, and instead of scrolling you will type event:checkout.failed, click one request ID, and read five lines that tell you exactly what happened to exactly one user. The whole investment — a 25-line file, a middleware, and a little restraint about what deserves info — pays for itself in that single query.
