
Isomorphic Next.js Logger with Pino
A freelance project required me to setup a logging system in order to track different events in the proposed system. For instance, logging authentication attempts, database changes, and generic events for debugging efforts.
Since the logs need to be stored in a structured format, the default console
object won’t cut it. Enter, pino, a lightweight logger for Node.js that can work in browsers through browserify. I chose it because it’s a battle-tested & most importantly, extensible library thanks to transports.
It’s my go-to library to be paired with an Axiom transport to create a robust, accessible, and lightweight logging solution for aggregating events in a small to medium-sized app.
Setup
pnpm i pino
Afterwards, you can use the code below:
import pino, { type Logger as PinoLogger } from "pino";
// #region Constants
const environment = process.env.NODE_ENV;
const COLOR = { GREEN: `\x1b[32m`, RED: `\x1b[31m`, WHITE: `\x1b[37m`, YELLOW: `\x1b[33m`, CYAN: `\x1b[36m`,};
const LEVEL_COLORS = { FATAL: COLOR.RED, ERROR: COLOR.RED, WARN: COLOR.YELLOW, INFO: COLOR.GREEN, DEBUG: COLOR.GREEN, TRACE: COLOR.GREEN,};
// #region Helpers
function formatTime(date: Date): string { const pad = (n: number, z = 2) => String(n).padStart(z, "0"); return ( pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds()) + "." + pad(date.getMilliseconds(), 3) );}
// #region Logger Class
/** * Application-level logger that supports structured logging with context. * * Intended for both server and browser environments. Logs are pretty-printed * in development and structured in production. Context (e.g., `userId`, `group`) * will be included in every log entry. * * @example * // Basic usage * const logger = new Logger(); * logger.info("App started"); * * @example * // With context * const authLogger = new Logger({ group: "auth", userId: "abc123" }); * authLogger.debug("User authenticated", { email: "user@example.com" }); * * @example * // Logging an error * const taskLogger = new Logger({ group: "queue" }); * taskLogger.error("Job failed", { jobId: "xyz789", reason: "timeout" }); * * @example * // Static logging (no context) * Logger.warn("Something unexpected happened", { code: 123 }); */export class Logger { // biome-ignore lint: context can be any type private context: Record<string, any>; private logger: PinoLogger;
// biome-ignore lint: context can be any type constructor(context: Record<string, any> = {}) { this.context = context; this.logger = Logger.baseLogger.child({ group: context.group ?? "default", }); }
// TODO: Server writable for pushing logs to persistence layer private static readonly baseLogger: PinoLogger = pino({ level: process.env.PINO_LOG_LEVEL || "trace", timestamp: pino.stdTimeFunctions.isoTime, browser: { asObject: true, write: (logObj) => { const { level, msg, group, time } = logObj as Record<string, string>;
// TODO: Asynchronously push log to a persistence layer
const levelUpper = level.toUpperCase(); const color = LEVEL_COLORS[levelUpper as keyof typeof LEVEL_COLORS] || COLOR.WHITE; const timeFormatted = formatTime(new Date(time)); const groupDisplay = group ? ` ${COLOR.CYAN}[${group}]` : "";
console.log( `[${timeFormatted}] ${color}${levelUpper}${groupDisplay} ${msg} ${COLOR.WHITE}`, logObj, ); }, formatters: { level: (label) => ({ level: label }), }, }, ...(environment === "production" ? {} : { transport: { target: "pino-pretty", options: { colorize: true, messageFormat: "[{group}] {msg}", }, }, }), });
private static formatPayload(data?: object) { return data ? { ...data, environment } : { environment }; }
private formatWithContext(data?: object) { const { group: _, ...restContext } = this.context ?? {}; return { ...Logger.formatPayload(data), ...(Object.keys(restContext).length > 0 ? { context: restContext } : {}), }; }
debug(msg: string, data?: object) { this.logger.debug(this.formatWithContext(data), msg); }
static debug(msg: string, data?: object) { Logger.baseLogger.debug(Logger.formatPayload(data), msg); }
info(msg: string, data?: object) { this.logger.info(this.formatWithContext(data), msg); } static info(msg: string, data?: object) { Logger.baseLogger.info(Logger.formatPayload(data), msg); }
warn(msg: string, data?: object) { this.logger.warn(this.formatWithContext(data), msg); }
static warn(msg: string, data?: object) { Logger.baseLogger.warn(Logger.formatPayload(data), msg); }
error(msg: string, data?: object) { this.logger.error(this.formatWithContext(data), msg); }
static error(msg: string, data?: object) { Logger.baseLogger.error(Logger.formatPayload(data), msg); }}
How does it work?
It has a single dependency and works in the client thanks to the browser.write
option; which hooks into client-side method invocations to allow you to define the formatting.
You can invoke the Logger
class via its static
methods directly, imitating the behavior of console
like so:
Logger.info("Hello World");
// {// "level": "info",// "time": "2025-06-27T05:32:19.001Z",// "msg": "Hello World",// "environment": "development"// }
Or alternatively, you can create a logger instance and pass shared context, which is useful for scoping the logs or tracking events:
const txLogger = new Logger({ group: "some-transaction-flow", uid: obfuscate(user.id), txId: generateTxId(),});
txLogger.info("Starting transaction!");// {// "level": "info",// "time": "2025-06-27T05:32:20.123Z",// "msg": "Starting transaction!",// "environment": "development",// "context": {// "group": "some-transaction-flow",// "uid": "abc123",// "txId": "TX-456"// }// }
try { // some business logic txLogger.info("Successfully completed."); // { // "level": "info", // "time": "2025-06-27T05:32:20.456Z", // "msg": "Successfully completed.", // "environment": "development", // "context": { // "group": "some-transaction-flow", // "uid": "abc123", // "txId": "TX-456" // } // }} catch (error) { txLogger.error("Something went wrong!", { error }); // { // "level": "error", // "time": "2025-06-27T05:32:20.789Z", // "msg": "Something went wrong!", // "environment": "development", // "context": { // "group": "some-transaction-flow", // "uid": "abc123", // "txId": "TX-456" // }, // "error": { // "name": "Error", // "message": "Some failure reason", // "stack": "Error: Some failure reason\n at ..." // } // }}
Server Usage
15 collapsed lines
import { Logger } from "@/features/shared/lib/logger";import { rpc } from "@/infrastructure/server/rpc";
export const dynamic = "force-dynamic";
export default async function Home() { const [helloRes, worldRes] = await Promise.all([ rpc.api.hello.$get(), rpc.api.world.$get(), ]); const [helloData, worldData] = await Promise.all([ helloRes.json(), worldRes.json(), ]);
const logger = new Logger({ group: "server" });
logger.debug("debug in a Server Component!"); logger.info("info in a Server Component!"); logger.warn("warn in a Server Component!"); logger.error("error in a Server Component!");9 collapsed lines
return ( <div> <h1> RPC payload from API: {helloData.message} {worldData.message} </h1> </div> );}
When we run next dev
, the output will be prettified to our console when because our NODE_ENV
is set to development
:
On NODE_ENV=production
with next start
, it’s logged as a compact JSON string for machine-friendly parsing:
Hono Middleware
We’ll be using a typed Hono Factory to create our middleware & application in order to preserve the types of the logger and other global context properties.
TIP
This is the recommended approach for ensuring type safety across Hono RPC endpoints.
import { createFactory } from "hono/factory";import type { RequestIdVariables } from "hono/request-id";import type { Logger } from "@/features/shared/lib/logger";
/** * If you're creating a new middleware that needs to attach a type to `c.var` or `c.env`, do it here. */type AppEnv = { Variables: { logger: Logger; } & RequestIdVariables;};
/** * Used to create middleware, handlers, and app to share definition of the Hono Env * Reference: https://hono.dev/docs/helpers/factory#createfactory */export const factory = createFactory<AppEnv>();
To scope it per request in our Hono server, we can assign a unique requestId
using the hono/request-id
middleware which we should run before the logger middleware.
import { getConnInfo } from "hono/vercel";import { Logger } from "@/features/shared/lib/logger";import { factory } from "../utils/factory";
/** * Attaches a `logger` instance to each request with the requestId included in the context. * Allows us to track the events during the lifecycle of the same request. * * Also, imitates the behavior of hono/logger to log out incoming & outgoing requests with their time taken (in miliseconds). * * Credits to: https://github.com/pinojs/pino/issues/1969#issuecomment-2311788254 */export const loggerMiddleware = factory.createMiddleware(async (c, next) => { const start = Date.now();
// Import the proper package based on your platform (Node, Bun, Cloudflare Workers, etc.) // See: https://hono.dev/docs/helpers/conninfo#import const connInfo = getConnInfo(c); const requestLoggerInstance = new Logger({ group: "server", requestId: c.var.requestId, method: c.req.method, path: c.req.path, ip: connInfo.remote.address, userAgent: c.req.header("user-agent") || "unknown", });
c.set("logger", requestLoggerInstance); c.var.logger.debug(`--> ${c.req.method} ${c.req.path}`);
await next();
const duration = Date.now() - start; c.var.logger.debug( `<-- ${c.req.method} ${c.req.path} ${c.res.status} in ${duration}ms` );});
And lastly, we mount the middleware to our Hono app after creating it using our typed factory helper:
import { requestId } from "hono/request-id";import { loggerMiddleware } from "./middleware/logger.middleware";import hello from "./routers/hello.router";import world from "./routers/world.router";import { factory } from "./utils/factory";
export const app = factory .createApp() .basePath("/api") .use(requestId()) .use(loggerMiddleware) .get("/log", (c) => { c.var.logger.info("Here's how to use the logger! It's typed!"); return c.json({ success: true }); });
const routes = app.route("/hello", hello).route("/world", world);
/** * @example * import { hc } from 'hono/client'; * * import type { AppType } from 'server'; * * const client = hc<App>(process.env.NEXT_PUBLIC_BASE_URL); // we have to pass a full URL like 'http://localhost:3000' */export type AppType = typeof routes;
And there we go! An end-to-end typed Hono RPC server with a global logger. Here’s a sample log after visiting the /api/log
endpoint.
Client Usage
On the client, it logs the message using plain ol’ console
and displays the context:
21 collapsed lines
"use client";
import { useEffect, useState } from "react";import { Logger } from "@/features/shared/lib/logger";import { rpc } from "@/infrastructure/server/rpc";
export default function Home() { const [count, setCount] = useState(0); const [hello, setHello] = useState<string | null>(null);
useEffect(() => { const fetchHello = async () => { const res = await rpc.api.hello.$get(); const data = await res.json();
setHello(data.message); };
fetchHello(); }, []);
const increment = () => { const logger = new Logger({ group: "client", context: "increment" }); logger.debug("debug in a Client Component!"); logger.info("info in a Client Component!"); logger.warn("warn in a Client Component!"); logger.error("error in a Client Component!"); setCount((c) => c + 1); };12 collapsed lines
return ( <div> <h1>Count is {count}</h1> <button type="button" onClick={increment}> Increment </button>
<p>{hello ?? "Loading from RPC API..."}</p> </div> );}
And the output will look like this:
Editing the Client-side Formatting
TIP
You can edit the output client-side formatting by customizing the browser.write
function inside src/lib/logger.ts
:
write: (logObj) => { const { level, msg, group, time } = logObj as Record<string, string>;
// TODO: Asynchronously push log to a persistence layer
const levelUpper = level.toUpperCase(); const color = LEVEL_COLORS[levelUpper as keyof typeof LEVEL_COLORS] || COLOR.WHITE; const timeFormatted = formatTime(new Date(time)); const groupDisplay = group ? ` ${COLOR.CYAN}[${group}]` : "";
console.log( `[${timeFormatted}] ${color}${levelUpper}${groupDisplay} ${msg} ${COLOR.WHITE}`, logObj, ); }, write: (log) => { const { level, msg, group, time, ...rest } = log; const tag = group ? `📦 [${group}]` : "📦"; const emoji = level === "info" ? "ℹ️" : level === "warn" ? "⚠️" : level === "error" ? "❌" : level === "debug" ? "🐛" : "🔍"; const timestamp = new Date(time).toLocaleTimeString(); console.log(`${emoji} [${timestamp}] ${level.toUpperCase()} ${tag} ${msg}`); if (Object.keys(rest).length) console.log("📎 Extra:", rest); },
You can customize it to look like anything you want! You may also want to use the other console
levels like console.info
, console.error
, console.warn
or even use another library to handle it for you.
🐛 [12:04:01 PM] DEBUG 📦 [client] debug in a Client Component!📎 Extra: { context: "increment" }
Going Beyond
This logger is a solid start for structured, isomorphic logging, but you can take it further.
As you can see from the TODO
comments, you can persist logs to databases or log management services for analysis.
You can also use log levels and sampling to control noise and lessen the storage consumption of your logs.