ehsan.blog
~/blog/how-client-logs-reach-your-terminal-in-nextjs-without-a-public-endpoint — zsh
cat how-client-logs-reach-your-terminal-in-nextjs-without-a-public-endpoint.md

How to log from the browser straight into your terminal in Next.js (without opening a public endpoint)

·8 min read

If you’ve ever debugged a client-side bug in a Next.js app, you know the pain. You add a console.log, ship it, ask the user (or QA, or yourself in another tab) to open DevTools, screenshot the console, and send it to you. That’s four extra steps for one log line. I got tired of this enough times that I built @developerehsan/nextjs-logger, a small package that lets you write log.info(...) anywhere in a Next.js app — server component, client component, server action, route handler — and have it show up in your terminal, the one running next dev or your production process, never in the browser’s console.

This post is about the actual problem that makes this hard, and how the library solves it. If you just want the “how do I use it” doc, the README covers that in more depth. Here I want to walk through why this isn’t a five-minute weekend project.

The naive version, and why it’s a security hole

The obvious first attempt: expose an API route that accepts a POST body and writes it to process.stdout.

ts
// app/api/log/route.ts — DON'T do this
export async function POST(req: Request) {
  const body = await req.json()
  console.log(body.message)
  return new Response('ok')
}

This works for exactly as long as nobody else finds the endpoint. The moment they do, you’ve built a log-injection and denial-of-service vector into your own infrastructure, for free. Anyone can:

  • POST thousands of requests per second and flood your terminal (or your log aggregator, if you ship stdout somewhere).
  • Send ANSI escape codes in the message field and mess with your terminal’s rendering, or worse, on some terminal emulators, trigger unexpected behavior.
  • Send a giant JSON body and eat your server’s memory parsing it.

None of this is exotic. It’s the same class of problem as any unauthenticated write endpoint — it’s just easy to overlook because “it’s just a log line” doesn’t feel like an attack surface. It is one.

What the real version has to solve

Once you take the endpoint seriously, three separate problems show up:

Who is allowed to call this? You need to verify the request actually came from your own app’s client, running in a real browser, not a script hitting the endpoint directly.

How much can they send? Even a legitimate client can flood you — a component re-rendering 60 times a second inside an animation loop, each render firing a debug log, will happily generate 60 requests per second if nothing throttles it.

What’s actually written to the terminal? The message body itself needs sanitizing before it touches stdout.write, or a log message becomes a way to inject control characters into your terminal.

nextjs-logger solves all three, and none of them individually is complicated — the work is in getting all three right and keeping the developer experience as simple as console.log.

Problem 1: authentication without shipping a secret to the browser

The obvious fix for “who can call this” is HMAC — sign the request with a secret, verify the signature server-side. The catch: the client can’t compute a real HMAC over each payload, because computing an HMAC requires the secret, and the secret can never reach the browser (if it did, anyone could read it out of the bundle and forge requests).

So instead, LoggerProvider — an async Server Component you mount once near your root layout — mints a session token on the server, once per page load: sign(secret, "session." + issuedAt). That token gets threaded down to the client and reused on every relay call for that page load (including the sendBeacon call fired on tab close). The server re-derives the expected token and compares it in constant time.

This authenticates “this request came from a session my server minted a few minutes ago” — not “these exact bytes are untampered,” which would need the client to hold the real secret. That’s an honest, deliberate trade-off: you get real protection against a random script POSTing fake logs, without shipping the signing key to every visitor’s browser. The session itself expires after 6 hours regardless of activity, so a captured token can’t be replayed indefinitely.

Two more layers sit behind the token: an origin/referer allowlist (weak on its own — a non-browser script can just omit those headers — but useful defense-in-depth against browser-driven cross-origin abuse), and hard caps on payload size (256 KB, checked via Content-Length before the body is even read) and entry count (100 per request).

Problem 2: a re-rendering component shouldn’t flood your server

This is the one people don’t think about until it bites them. Say you log inside a component that re-renders on every animation frame:

tsx
'use client'
import { log } from '@developerehsan/nextjs-logger'

export function DragHandle({ x, y }: { x: number; y: number }) {
  log.debug('DragHandle position', { x, y }) // called on every render
  return <div style={{ transform: `translate(${x}px, ${y}px)` }} />
}

Without any throttling, dragging something around the screen for two seconds could fire 100+ relay requests. nextjs-logger puts each log level behind its own TanStack Pacer strategy, so different levels get different treatment based on how urgent they actually are:

LevelStrategyWhy
debugthrottle, 500mshigh volume, low urgency — smooth the firehose
infothrottle, 300msfrequent but more actionable
warndebounce, 200msconsolidate bursts into one flush
errorrate limit, 10 per 5smust never flood, but must arrive promptly
fatalrate limit, 3 per 10srare by definition — hard cap regardless

The distinction between throttle and debounce matters here. Throttle lets one call through immediately, then ignores calls for the window — good for “I don’t need every single position update, just periodic samples.” Debounce waits for a quiet period before firing — good for “collapse a burst of warnings into one representative flush” rather than sampling arbitrarily. Rate limit is different again: it’s a hard cap on count over a window, which is what you want for error and fatal — you’d rather see the first 10 errors in 5 seconds and know there were more, than silently drop entries or let an error storm take down your relay endpoint.

All of this is on top of a ring buffer (maxQueueSize, default 500) that evicts the oldest entries rather than growing memory unboundedly if something goes really wrong, plus navigator.sendBeacon on tab close so queued-but-not-yet-flushed logs aren’t silently lost.

Problem 3: what actually reaches your terminal

Once a request clears authentication and throttling, the content itself still needs handling. Every message is sanitized — ANSI escape sequences, carriage returns, and null bytes are stripped — before anything touches stdout.write. This matters more than it sounds: your terminal emulator interprets those bytes, so an unsanitized log message is a way for user-controlled input (an error message from a third-party API, a username someone chose deliberately) to manipulate your terminal’s rendering.

There’s also a redactKeys list — password, token, secret, apiKey, authorization, cookie, and a few more, plus anything matching /token$/i or /secret$/i — applied to structured data before it’s written or relayed, so a log call like log.info('login', { password: req.body.password }) doesn’t accidentally leak a plaintext password into your terminal history or wherever stdout ends up being shipped.

Putting it together

The setup is genuinely three files:

ts
// app/layout.tsx — mount once
import { LoggerProvider } from '@developerehsan/nextjs-logger/provider'
ts
// app/api/log-relay/route.ts — the whole file
export { POST } from '@developerehsan/nextjs-logger/relay/route-handler'
ts
// anywhere, no hooks, no context needed
import { log } from '@developerehsan/nextjs-logger'
log.info('user signed in', { userId })

Everything above — the session token minting, the per-level Pacer policies, the sanitization — happens inside the package. You just get console.log-shaped calls that land in your terminal no matter where in the request lifecycle they’re called from.

The one gotcha worth calling out: don’t name that route folder something starting with an underscore. Next.js treats _folder as a private folder and excludes it from routing entirely — the handler silently never mounts, and logging quietly falls back to the slower Server Action transport instead of erroring loudly. I found this out the hard way while testing custom relay paths, which is exactly why the provider now warns in development if it spots an underscore-prefixed segment in a custom relayUrl.

Building this taught me that “just relay console.log to the server” is one of those problems that’s trivial until you refuse to ship something insecure or something that can be accidentally DoS’d by your own animation code. Getting both right, while keeping the call site as simple as console.log, was the actual project.

Once the entries reach the server, they hit a second buffer on their way out to whatever ships them onward — and that one has its own way of losing the last few lines, which I measured in why your serverless function loses its last logs.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.