Structured logging in Next.js means emitting your logs as JSON to standard output, one object per line, so Vercel's Runtime Logs can filter them by field instead of making you eyeball a wall of text. console.log still works on Vercel's serverless runtime. What it throws away is the structure that makes logs searchable when an incident is live and a hundred requests are failing at once.
The gap shows up at the worst possible moment. A customer emails that their checkout failed, you open the Vercel dashboard, and you're scrolling raw strings with no way to isolate their request from everyone else's. Structured logging fixes that, but the patterns that work on a long-running Node server break on serverless in ways that are easy to miss until production.
TL;DR:
console.logsurvives Vercel's serverless runtime but ships no queryable structure. Vercel captures whatever you send to stdout, so the fix is to print JSON, not prose [1].- The long-running-Node playbook (a log file on disk, a module-global logger holding the current request) breaks on serverless: the filesystem is read-only [2] and instances are reused across concurrent requests.
- Use a JSON logger like Pino, attach a per-request ID with Node's
AsyncLocalStorage[7], and correlate user-facing errors by their Next.jsdigest[6]. - Redact secrets at the logger, not at each call site, and gate log volume by level. Vercel meters Runtime Logs and Log Drains bill per GB [3].
Table of contents
- Why doesn't console.log hold up on Vercel?
- What does structured logging actually change?
- How do you set up a JSON logger in Next.js?
- How do you attach a request ID to every log line?
- How do you keep secrets out of your logs?
- What about log levels, retention, and cost?
- Wire logging in before the first incident
- Common questions about logging in Next.js
Why doesn't console.log hold up on Vercel?
console.log works on Vercel: Runtime Logs capture everything your function sends to console.log, console.warn, and console.error [1]. What it doesn't give you is structure. A line like Webhook handler error: [object Object] has no level field, no request ID, and no way to filter down to one user's failure when the dashboard is scrolling.
Here is what SecureStartKit's Stripe webhook handler logs today. It's honest, development-grade logging: strings and objects piped to console.error.
// app/api/webhooks/stripe/route.ts (what ships today)
console.error('Webhook signature verification failed:', err)
console.error('Failed to record webhook event:', claimError)
console.error('Payment failed for invoice:', invoice.id)
console.error('Webhook handler error:', error)
That is fine for local development and light production. It stops being fine the moment you need to answer "what happened to this request." There is no field to filter on, and the same handler can log six different shapes depending on which branch failed.
Two serverless facts make the naive upgrades fail:
- There is no writable disk for a log file. Vercel functions run on a read-only filesystem with a writable
/tmpscratch space up to 500 MB [2], and/tmpis ephemeral. The Winston-to-app.logpattern from a long-running server has nowhere to write. On serverless, stdout is the log transport. - Instances are reused across concurrent requests. Vercel's Fluid Compute reuses a function instance across concurrent invocations to cut cold starts. A module-level
let currentRequestIdshared by every request on that instance will tag log lines with the wrong request under any real concurrency. Functions are also archived when idle [2], so nothing you stash in a global survives reliably anyway.
The takeaway: log to stdout, as JSON, and carry per-request context in something built for concurrency rather than a shared variable.
What does structured logging actually change?
Structured logging replaces free-text log lines with one JSON object per line, where every value you care about is its own field. By outputting structured JSON, your custom fields (error IDs, durations, counts) become filterable and searchable directly in the Vercel Logs dashboard [1], instead of buried inside a string you have to grep by eye.
Compare the two. The unstructured line:
Webhook handler error: Error: insert into "purchases" failed
The structured line:
{"level":50,"time":1751558400000,"requestId":"a1b2c3d4","eventId":"evt_1NX","msg":"stripe webhook failed"}
The second one you can filter by requestId to see everything that happened in that single invocation, by eventId to trace one Stripe event, or by level:error to see only failures. The Runtime Logs dashboard filter (level:error, level:warning, level:info) reads the level inferred from the console method used [1], so even the level becomes a first-class filter rather than a prefix you scan for.
Nothing here requires an external service. The whole win comes from changing what you print, not where you send it.
How do you set up a JSON logger in Next.js?
Use a JSON logger built for Node's server runtime. Pino is the common choice: it's a very low overhead JavaScript logger [4] that outputs newline-delimited JSON by default, which is exactly the shape Vercel's Runtime Logs and any Log Drain consumer expect. Define one logger module and import it everywhere your server code runs.
// lib/logger.ts
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { env: process.env.VERCEL_ENV ?? 'development' },
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'password', 'token', 'email'],
censor: '[redacted]',
},
})
Three deliberate choices in that config:
- Level from an env var.
LOG_LEVELlets you raise the floor towarnin production without a redeploy of code, and drop todebugwhen you're chasing something. - A
baseobject stamps every line with shared context (here, the environment) so you never add it by hand. - Redaction is declared once, not repeated at every call site. More on that below.
One serverless caveat that trips people up: avoid Pino's in-process transports (like pino-pretty) in the production build. Transports spawn a worker thread that the serverless bundler can't always resolve, and you want raw JSON on Vercel anyway. Keep production logging to plain JSON on stdout. In local development, pipe the dev server's output through pino-pretty from the shell if you want colorized lines, so the pretty-printer never enters the deployed bundle.
Then replace the ad-hoc console.error calls with structured events. Instead of a string and an object, pass a fields object first and a short message second:
// app/api/webhooks/stripe/route.ts (upgraded)
import { logger } from '@/lib/logger'
logger.error({ err, eventId: event.id }, 'stripe webhook failed')
Pino serializes the err into a structured stack, keeps eventId as a filterable field, and the message stays a stable, greppable constant. This pattern pairs with the error-handling and Sentry setup guide, which covers the error.tsx boundary and the onRequestError hook that catches server render failures.
How do you attach a request ID to every log line?
Attach a request ID by opening a per-request store with Node's AsyncLocalStorage at the top of each handler, then reading it wherever you log. AsyncLocalStorage stores data throughout the lifetime of a web request or any other asynchronous duration [7], and the store stays coherent through async operations [7], which is exactly the concurrency guarantee a module-global variable can't give you.
The pattern is two small helpers: one to open the store, one to get a logger already bound to the current request's ID.
// lib/log-context.ts
import { AsyncLocalStorage } from 'node:async_hooks'
import { logger } from './logger'
const storage = new AsyncLocalStorage()
export function withRequestLogger(requestId, fn) {
return storage.run({ requestId }, fn)
}
export function log() {
const ctx = storage.getStore()
return ctx ? logger.child({ requestId: ctx.requestId }) : logger
}
logger.child({ requestId }) returns a child logger [4] that stamps requestId on every line it writes, so you never thread the ID through function arguments. Wrap the handler body once, generate an ID, and echo it back to the caller so a user reporting a failure can hand you the exact key:
// app/api/webhooks/stripe/route.ts
import { randomUUID } from 'node:crypto'
import { withRequestLogger, log } from '@/lib/log-context'
export async function POST(request: Request) {
const requestId = randomUUID()
return withRequestLogger(requestId, async () => {
log().info({ path: '/api/webhooks/stripe' }, 'request received')
// ... verify signature, process event ...
// On failure: log().error({ err, eventId }, 'stripe webhook failed')
return Response.json({ received: true }, { headers: { 'x-request-id': requestId } })
})
}
For user-facing render errors, you get correlation for free. When a Server Component or Server Action throws in production, Next.js hands the error boundary an Error & { digest?: string } [6], and that same digest is what the user sees on the fallback screen. Log the digest as a field (the onRequestError hook covered in the error-handling guide is where you capture it), and a screenshot of "something went wrong, reference a1b2c3" maps straight to the server log line. If you forward logs with a Log Drain, Vercel's pipeline also includes a requestId in the metadata for every entry [1], so drain consumers can correlate even records you didn't tag yourself.
How do you keep secrets out of your logs?
Redact at the logger, not at each call site. A logger-level redaction rule strips sensitive values no matter which code path logged them, which is the only approach that survives a refactor. Pino takes a redact option where you supply paths to keys that hold sensitive data [5], and it replaces them with [Redacted] (or a custom censor string, or removes them entirely with remove: true) [5].
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token', 'email'],
censor: '[redacted]',
}
The reason boundary redaction beats hand-scrubbing is drift. If you rely on each logger.error call to remember not to include the auth header, one new call site added six months later reopens the leak. A path rule on the logger closes it once. This matters because logging secrets or personal data is the observability half of OWASP A09 (security logging failures), covered in the OWASP Top 10 for Next.js and Supabase guide, and it's an explicit item on the pre-launch security audit.
Redaction at your application logger is the first layer, not the whole story. Once logs travel to an error tracker, that tool has its own default data collection to lock down. The guide to filtering PII from Sentry covers that second layer in depth, so keep the two separate: your logger controls what you print, the tracker controls what it stores.
What about log levels, retention, and cost?
Log levels are the cost-control lever on serverless, because Vercel meters Runtime Logs and Log Drains bill per gigabyte. Set a production floor of info or warn, reserve debug for local work, and use error deliberately so level:error in the dashboard stays a signal rather than noise. Volume is not free the way it feels on a box you already pay for.
The plan realities to design around:
- Runtime Log retention is short and plan-dependent. Vercel functions are also archived when they aren't invoked (within two weeks for production, 48 hours for previews) [2], so the dashboard is a live tail, not a long-term store. Don't treat it as your audit trail.
- Log Drains are a paid, Pro-and-up feature. Drains are available to all users on the Pro and Enterprise plans, and Hobby or Pro Trial accounts need to upgrade to Pro to access non-audit-log drains [3]. That's the mechanism for shipping logs to a system that keeps them.
- Drains bill by volume. Drains usage is priced at $0.50 per GB [3], which is the direct reason level discipline and not logging full request bodies pays off. Every noisy
infoline at scale is a line item.
The design rule that falls out: log structured events with the fields you'll actually query (IDs, durations, outcomes), keep the level floor honest, and let a drain forward the survivors to wherever you retain them. Structure makes the logs cheap to search; levels keep them cheap to store.
Wire logging in before the first incident
The move from console.log to structured logging is small and mechanical: one logger module, one AsyncLocalStorage wrapper, redaction declared once, and levels gated by an env var. The payoff is that the first time production breaks, you can filter to the one request that matters instead of reading the whole stream. On serverless the stakes are higher than on a single server, because there's no log file to fall back on and instance reuse punishes any per-request state you keep in a global.
Do it before you need it. Retrofitting request IDs during an outage is exactly the wrong time to learn that your logger was a shared variable. If you'd rather start from a codebase where backend-only data access, validated inputs, and the production patterns around them are already wired, that's the foundation the SecureStartKit template is built on.
Common questions about logging in Next.js
Does console.log work in Next.js on Vercel?
Yes. Vercel Runtime Logs capture everything your function sends to console.log, console.warn, and console.error [1]. It works but stays unstructured: you get text lines with no filterable fields, which is why structured JSON logging is the upgrade for anything past light traffic.
Why can't I just write logs to a file?
Vercel functions have a read-only filesystem with only a writable /tmp scratch space up to 500 MB [2], and /tmp is ephemeral. There's no persistent disk to append a log file to, so serverless logging goes to stdout, which Vercel captures for you.
How do I add a request ID to logs without passing it everywhere?
Open an AsyncLocalStorage store at the top of the handler and read it in your logger. AsyncLocalStorage lets you store data throughout the lifetime of a web request [7], and a Pino child logger [4] stamps that ID on every line, so you never thread it through function signatures.
Do I need Sentry or a Log Drain? Not to get structured logs. JSON to stdout is filterable in the Vercel dashboard on any plan [1]. You add a Log Drain (Pro and up, billed at $0.50 per GB [3]) or an error tracker when you need long-term retention, alerting, or the deeper error context those tools provide.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- Add structured application logs to Vercel Functions— vercel.com
- Runtimes | Vercel Functions— vercel.com
- Working with Drains— vercel.com
- Pino: super fast, all natural JSON logger for Node.js— github.com
- Pino Redaction— github.com
- Error Handling | Next.js— nextjs.org
- Asynchronous context tracking | Node.js— nodejs.org
Related Posts
How Much Does a SaaS Cost to Run in 2026? Real Numbers
A solo SaaS on Vercel + Supabase + Stripe runs $0 at MVP, ~$50 at first revenue, and under $250 to 10K users. The exact monthly line items.
Next.js SEO for SaaS: The Complete 2026 Guide
A security-first guide to SaaS SEO in 2026. Learn how to leverage Next.js rendering, structure high-intent pages, and protect your app from indexing leaks.
Next.js Secrets: 4 Ways to Share Them Safely [2026]
Committing a .env to share secrets leaks them into git history forever. Compare the safe ways to share environment variables across a Next.js team.