Skip to content
Express.js ex libraries 4 min read

morgan HTTP Logger

Every production Express app needs a record of who hit which endpoint, how long it took, and what status came back. Writing that logging logic by hand quickly turns into noise, so morgan — maintained by the Express team — does it as a single line of middleware. It supports predefined formats for development and production, lets you compose your own log lines from custom tokens, and can pipe output to files or a structured logger like winston. This page covers configuring all of those.

Installing and enabling morgan

morgan is a standalone package. Install it and register it with app.use() early in the middleware stack so it wraps every request that follows.

npm install morgan
const express = require("express");
const morgan = require("morgan");

const app = express();

// `dev` is a concise, color-coded format for local development.
app.use(morgan("dev"));

app.get("/users", (req, res) => {
  res.json([{ id: 1, name: "Ada" }]);
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

A request to GET /users prints a single colored line to stdout.

Output:

GET /users 200 4.218 ms - 27

The token order is method, URL, status, response time, and content length. Status codes are colored by class (green 2xx, cyan 3xx, yellow 4xx, red 5xx), which makes failures jump out during development.

Predefined formats

morgan ships several named formats so you rarely need to write your own. Pick dev for local work and combined (the Apache standard) for production, since most log aggregators and analytics tools already understand it.

FormatBest forExample output
devLocal devGET /users 200 4.218 ms - 27
tinyMinimal logsGET /users 200 27 - 4.218 ms
shortCompact prod::1 - GET /users HTTP/1.1 200 27 - 4.218 ms
commonCLF baseline::1 - - [14/Jun/2026:09:12:01 +0000] "GET /users HTTP/1.1" 200 27
combinedProductionAdds Referer and User-Agent to common
app.use(morgan(process.env.NODE_ENV === "production" ? "combined" : "dev"));

A combined line includes the client address, timestamp, request line, status, size, referer, and user agent:

Output:

::1 - - [14/Jun/2026:09:12:01 +0000] "GET /users HTTP/1.1" 200 27 "-" "curl/8.4.0"

Custom tokens and formats

When the built-in formats are missing something — a request ID, the authenticated user, a response header — define a token with morgan.token(name, fn) and reference it as :name in a format string. The callback receives the request and response objects.

const { randomUUID } = require("node:crypto");

// Attach an id to each request, then expose it as a token.
app.use((req, res, next) => {
  req.id = randomUUID();
  next();
});

morgan.token("id", (req) => req.id);
morgan.token("user", (req) => req.user?.email ?? "anonymous");

app.use(
  morgan(':id :method :url :status :user :response-time ms')
);

Output:

9f1c2a8e-... GET /users 200 [email protected] 3.901 ms

Define custom tokens before the morgan() call that uses them, and register the middleware that populates req.id or req.user before morgan, otherwise the token reads undefined.

Piping logs to a stream

By default morgan writes to process.stdout. Pass a stream option to redirect output — for example, appending to a rotating access log file. Combine it with the skip option to keep noisy successful requests out of an error-only log.

const fs = require("node:fs");
const path = require("node:path");

const accessLog = fs.createWriteStream(
  path.join(__dirname, "access.log"),
  { flags: "a" } // append, do not truncate on restart
);

app.use(morgan("combined", { stream: accessLog }));

// A separate stream that only records server errors.
app.use(
  morgan("combined", {
    skip: (req, res) => res.statusCode < 500,
    stream: fs.createWriteStream(path.join(__dirname, "error.log"), { flags: "a" }),
  })
);

In containerized deployments, prefer logging to stdout and letting the platform handle collection and rotation; reserve file streams for traditional servers.

Piping morgan into winston

For structured JSON logs, route morgan through winston instead of writing plain text. morgan’s stream only needs a write(message) method, so you can hand it an object that forwards each line to a winston logger.

const winston = require("winston");

const logger = winston.createLogger({
  level: "info",
  format: winston.format.json(),
  transports: [new winston.transports.Console()],
});

// morgan appends a newline; trim it before passing to winston.
const stream = {
  write: (message) => logger.http(message.trim()),
};

app.use(morgan("combined", { stream }));

Output:

{"level":"http","message":"::1 - - [14/Jun/2026:09:12:01 +0000] \"GET /users HTTP/1.1\" 200 27 \"-\" \"curl/8.4.0\""}

This pattern works identically on Express 4.x and 5.x — morgan’s middleware contract is unchanged across versions, since both still call next() synchronously after recording the request.

Best Practices

  • Register morgan early — before routers and error handlers — so it logs every request, including ones that error out.
  • Use dev locally for readability and combined in production for tool compatibility.
  • Define custom tokens and the middleware that feeds them before the morgan() call that uses them.
  • Use the skip option to drop health-check and static-asset noise, or to split error-only logs.
  • In containers, log to stdout and let the orchestrator handle rotation; use file streams only on traditional hosts.
  • For structured logs, pipe morgan into winston rather than parsing plain-text lines downstream.
  • Never log full request bodies or Authorization headers — they routinely contain secrets and personal data.
Last updated June 14, 2026
Was this helpful?