Next.js applications, when running in production, often face the challenge of generating logs that are not only human-readable but also machine-parseable, enabling efficient analysis and alerting. The default Next.js logger is basic and not designed for this.

This is where Pino shines. Pino is a fast, low-overhead JSON logger for Node.js. Integrating it into Next.js allows for structured logging, which means each log entry is a JSON object with consistent keys. This makes filtering, searching, and analyzing logs significantly easier in production environments.

Let’s see how to set this up.

First, install Pino:

npm install pino pino-pretty --save-dev

pino-pretty is for local development to make the JSON output human-readable.

Now, create a custom logger instance. A common pattern is to have a lib/logger.js file:

// lib/logger.js
import pino from 'pino';

const logger = pino({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  formatters: {
    level: (label) => {
      return { level: label };
    },
    bindings: () => ({}), // Remove default bindings like pid, hostname
  },
  timestamp: () => `,"time":"${new Date().toISOString()}"`,
});

export default logger;

In this setup:

  • level: Dynamically sets the log level based on NODE_ENV. Production gets info and above, while development gets debug and above.
  • formatters.level: Customizes the log level key to just level instead of the default level.
  • formatters.bindings: Clears out default JSON bindings like pid and hostname to keep logs cleaner and more focused on application data.
  • timestamp: Ensures a consistent ISO 8601 format for timestamps.

Next, we need to integrate this logger into Next.js API routes and potentially server-side rendering logic.

For API routes (pages/api/hello.js):

// pages/api/hello.js
import logger from '../../lib/logger';

export default function handler(req, res) {
  logger.info({ message: 'API route accessed', method: req.method, query: req.query });
  res.status(200).json({ name: 'John Doe' });
}

Here, we import our logger and use logger.info to log details about the incoming request. The object passed to logger.info becomes part of the JSON log entry.

For server-side rendering (e.g., pages/index.js getServerSideProps):

// pages/index.js
import logger from '../lib/logger';

export async function getServerSideProps(context) {
  logger.info({ message: 'getServerSideProps executed', url: context.resolvedUrl });
  return {
    props: {
      data: 'some data',
    },
  };
}

function HomePage({ data }) {
  return <div>Welcome! {data}</div>;
}

export default HomePage;

Again, we import and use the logger to track execution flow.

To see the logs during development, you’ll want to use pino-pretty. You can configure this in your package.json scripts:

// package.json
"scripts": {
  "dev": "next dev | pino-pretty",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}

When you run npm run dev, the output from next dev will be piped through pino-pretty, which will format the JSON logs into a human-readable, colored output.

In production, you would typically run your Next.js app using next start and ensure your logging is directed to stdout and stderr. Log aggregation tools like Datadog, Splunk, or ELK stack can then collect these JSON logs, parse them, and make them searchable.

The real power comes when you start adding context. Instead of just logging a message, log an object that contains relevant IDs, user information, or request details.

// pages/api/users/[id].js
import logger from '../../../lib/logger';

export default function handler(req, res) {
  const { id } = req.query;

  if (!id) {
    logger.warn({ message: 'User ID not provided', query: req.query });
    return res.status(400).json({ error: 'User ID is required' });
  }

  logger.info({ message: 'Fetching user', userId: id });

  // Simulate fetching user data
  const user = { id: id, name: 'Jane Doe' };

  if (!user) {
    logger.error({ message: 'User not found', userId: id });
    return res.status(404).json({ error: 'User not found' });
  }

  logger.info({ message: 'User fetched successfully', userId: id });
  res.status(200).json(user);
}

This approach allows you to trace a specific user’s request through your application by filtering logs by userId. The structured nature means you can query for all error level logs related to a specific userId, or count how many times a particular API route was accessed with a certain method.

Crucially, when deploying to platforms like Vercel or Netlify, stdout and stderr are captured by default and are accessible through the platform’s logs interface. For self-hosted Node.js environments, ensure your process manager (like PM2) is configured to capture these streams.

The core benefit is moving from a stream of disconnected text lines to a stream of structured, queryable events. This is not just about making logs readable; it’s about making them actionable. You can set up alerts for specific error patterns, track performance metrics by counting log entries, and drill down into issues with unprecedented ease.

The next challenge you’ll encounter is managing log levels dynamically without restarting your application, especially in production.

Want structured learning?

Take the full Nextjs course →