Building a Scalable TypeScript Backend Boilerplate with Winston Logging

In modern backend development, TypeScript has become a powerful tool for building scalable and maintainable applications. Its static type-checking and robust tooling make it a preferred choice for developers who want to catch errors early and ensure code quality.

However, setting up a TypeScript project from scratch can be difficult, especially for beginners. How do we configure TypeScript with Node.js? How do we set up a project to handle different environments like local, development, and production? What’s the best way to handle logging, errors, and debugging?

In this blog, we will build a TypeScript backend boilerplate using Express and Winston for logging. This guide will cover everything from initializing the project to configuring it for different environments and handling logging. We will explain key concepts such as environment configuration, project structure, and how to efficiently work with TypeScript in Node.js.

Who is this Guide for?

Whether you are:

  • A beginner looking to start building Node.js projects with TypeScript
  • An intermediate developer seeking to add logging, environments, and better structuring to your projects
  • An advanced developer wanting to enhance your backend architecture

This blog will help you build a boilerplate that you can use as the foundation for any TypeScript-based backend.

Why TypeScript with Node.js?

Before jumping into the boilerplate, let's address a common question: Why should you use TypeScript in a Node.js backend?

  1. Static Typing: TypeScript’s static typing helps you catch bugs early in the development process. This is especially useful for large-scale applications where maintaining consistent data types is crucial.
  2. Improved IDE Support: TypeScript provides autocompletion, refactoring tools, and error-checking in real-time. This leads to a smoother development experience, especially when using VSCode or other TypeScript-friendly editors.
  3. Maintainability: With TypeScript, you can write cleaner, self-documented code that’s easier to refactor as the project grows.
  4. Ecosystem: The TypeScript ecosystem is mature, with typings available for most popular Node.js libraries, making it easier to integrate with third-party tools and packages.

Why Not Just Use nodemon? Why Use ts-node-dev Instead?

When developing with Node.js, many developers use nodemon to automatically restart the server when code changes are made. However, when working with TypeScript, there are better options. Here's why:

Nodemon in TypeScript Projects

  • nodemon only works with compiled JavaScript files. Since TypeScript needs to be transpiled into JavaScript, nodemon isn't ideal for TypeScript projects. You would need to compile the .ts files to .js first and then restart the server, which adds unnecessary steps and complexity.
  • This is where ts-node-dev comes into play.

Why ts-node-dev?

  • Transpiling on the Fly: ts-node-dev combines ts-node (which allows running TypeScript directly without manual compilation) with live reload functionality. It watches your .ts files and restarts the server when changes are detected, without needing to manually compile TypeScript into JavaScript first.
  • Faster Restarts: ts-node-dev caches transpiled modules and only re-transpiles those that changed, leading to faster restarts compared to alternatives like nodemon + tsc.

For TypeScript development, ts-node-dev provides the best of both worlds: live reloading and the ability to run TypeScript files directly.

npm install ts-node-dev --save-dev

Now, you can use ts-node-dev to run your development server without the need for an additional compilation step:

ts-node-dev src/index.ts

What’s the Difference Between ts-node-dev and ts-node?

  • ts-node: Allows you to execute TypeScript files directly without compiling them into JavaScript. It's a great tool for quick scripts or running TypeScript without setting up complex build pipelines.
  • ts-node-dev: Extends ts-node by adding live-reload features and faster restarts during development. This makes it more suitable for long-running backend services that require frequent updates during development.

Setting Up the Project

Now that we’ve covered the basic tools, let’s move forward with setting up the project. In this section, we will set up a TypeScript Node.js backend using Express and configure everything for scalability and maintainability. Here's an outline of what we'll cover:

  1. Initializing a Node.js project with TypeScript support
  2. Structuring the project for scalability
  3. Configuring environment variables for local, development, and production setups
  4. Adding Winston logger for better logging
  5. Implementing routes and controllers
  6. Setting up middleware and error handling

Step 1: Initializing Your TypeScript Project

Let’s start by initializing the project and installing the necessary dependencies:

mkdir typescript-backend-boilerplate
cd typescript-backend-boilerplate
npm init -y

This will generate a basic package.json file. Next, install the necessary packages:

npm install express winston winston-daily-rotate-file dotenv morgan module-alias
npm install --save-dev typescript ts-node-dev @types/express @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser
  • Express: The web framework we'll be using to handle routes, requests, and responses.
  • Winston: A versatile logging library that helps us log errors, information, and warnings in different formats.
  • dotenv: A package that loads environment variables from .env files, making it easier to manage configurations.
  • morgan: An HTTP request logger middleware for Node.js that we will integrate with Winston to handle logging.
  • ts-node-dev: As explained earlier, this package lets us run TypeScript code directly with live reload.

We also install the necessary TypeScript types and linters (@types/express, @types/node, and ESLint) to help us maintain code quality.

Here is package.json file and also setup running environments:
{
  "name": "typescript-backend-boilerplate",
  "version": "1.0.0",
  "description": "Typescript backend boilerplate including winston logger, http logs and much more.",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "lint": "eslint -c eslint.config.mjs",
    "local": "cross-env NODE_ENV=local ts-node-dev --respawn index.ts",
    "dev": "cross-env NODE_ENV=development ts-node-dev --respawn index.ts",
    "start": "cross-env NODE_ENV=production node dist/index.js"
  },
  "keywords": [
    "typescript project",
    "winston logger",
    "http logs",
    "nodejs backend logging"
  ],
  "author": "Usama Amjid",
  "license": "ISC",
  "devDependencies": {
    "@eslint/js": "^9.12.0",
    "@types/express": "^5.0.0",
    "@types/morgan": "^1.9.9",
    "@types/node": "^22.7.5",
    "@typescript-eslint/eslint-plugin": "^8.8.1",
    "@typescript-eslint/parser": "^8.8.1",
    "eslint": "^9.12.0",
    "eslint-plugin-security": "^3.0.1",
    "globals": "^15.11.0",
    "nodemon": "^3.1.7",
    "prettier": "3.3.3",
    "ts-node": "^10.9.2",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.6.0",
    "typescript-eslint": "^8.8.1"
  },
  "dependencies": {
    "cross-env": "^7.0.3",
    "dotenv": "^16.4.5",
    "express": "^4.21.1",
    "module-alias": "^2.2.3",
    "morgan": "^1.10.0",
    "winston": "^3.15.0",
    "winston-daily-rotate-file": "^5.0.0"
  },
  "_moduleAliases": {
    "@config": "src/config",
    "@constants": "src/constants",
    "@controllers": "src/controllers",
    "@models": "src/models",
    "@routes": "src/routes",
    "@types": "src/types",
    "@utils": "src/utils"
  }
}

Step 2: Configuring tsconfig.json

The tsconfig.json file is crucial for setting up TypeScript in a Node.js environment. It tells the TypeScript compiler how to handle TypeScript files, where to output compiled JavaScript, and how to resolve paths.

Here’s a basic tsconfig.json file:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "commonjs",
    "rootDir": ".",
    "outDir": "dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "baseUrl": ".",
    "paths": {
      "@config": ["src/config"],
      "@constants": ["src/constants"],
      "@controllers": ["src/controllers"],
      "@models": ["src/models"],
      "@routes": ["src/routes"],
      "@types": ["src/types"],
      "@utils": ["src/utils"]
    }
  },
  "include": [
    "src",
    "**/*.ts",
    "**/*.js"
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts",
    "dist"
  ]
}
Key Configurations:
  • target: Specifies the ECMAScript version for the output JavaScript (ES2023 in this case).
  • module: We use CommonJS, which is the module system used by Node.js.
  • rootDir and outDir: These specify the source directory (src) and where the compiled files should be placed (dist).
  • paths: Custom path mappings that allow us to use aliases like @config, @controllers, etc. instead of relative paths like ../../../config.

Step 3: Environment Configuration

Managing different environments (local, development, production) is crucial for any application. We’ll use .env files to configure different environment settings.

Create three files:

  1. .env.local
  2. .env.dev
  3. .env.production

Each file will have different configurations depending on the environment. For example:

.env.local:
NODE_ENV=local
PORT=3000
DATABASE_URL=mongodb://localhost:27017/dev-db
.env.production:
NODE_ENV=production
PORT=8000
DATABASE_URL=mongodb://prod-server:27017/prod-db
In config/keys.ts:
import dotenv from "dotenv";
dotenv.config({ path: `./.env.${process.env.NODE_ENV}` });

const ENV = {
  PORT: process.env.PORT || 3000,
  DATABASE_URL: process.env.DATABASE_URL,
};

export default ENV;

This setup automatically picks the correct .env file based on the environment (e.g., local, dev, production).

Step 4: Setting Up Winston Logging

Setting up logging is essential for monitoring, debugging, and understanding how your application behaves in different environments. In this step, we are configuring Winston, a versatile logging library, to handle logging with customized levels, formats, and file rotation. This configuration provides structured, formatted logs and ensures they are managed efficiently, especially when the application scales or runs in production.

Why do we need a logger?

  • Monitoring: Helps track the application's state by recording events such as successful operations, warnings, or errors.
  • Debugging: Logs provide detailed information when diagnosing issues.
  • Auditability: Maintains records of actions and state changes for auditing or security reviews.
  • Separation of Environments: In production, logging needs to be more structured and permanent (stored in files), while in development, it’s often more convenient to see logs in the console.
Winston Setup Explained

We use Winston for logging because it supports multiple transports (Console, File, HTTP, etc.), custom log levels, and flexible formatting. The goal here is to create a centralized, reusable logger class that we can use across the entire application.

Key Components:
  1. customLevels: Defines our custom log levels. For example, "trace" or "fatal" can be added on top of the default levels. This gives us more granular control over the types of logs.
  2. formatter: Customizes how log messages are formatted. The formatter includes colorized output for easy readability, timestamps, and the option to format the log messages and metadata.
  • timestamp: Adds the current date and time to each log entry.
  • format.splat(): Ensures the proper handling of string interpolation in log messages.
  • format.printf(): Combines all the elements into a human-readable format.
  1. DailyRotateFile: This transport is used to rotate logs daily, which helps manage log sizes and keeps them organized by date. This is critical for production environments where logs can grow large quickly.
  2. isDevEnvironment(): Determines whether the application is running in a development environment. It sets the logging level to "trace" (very detailed logs) for development and a more restricted level like "info" for production. This prevents production logs from being overwhelmed by verbose output.
Logger Class Breakdown:

The Logger class encapsulates Winston’s functionality, making it reusable and more manageable across the application. Each method inside this class (like trace(), debug(), info(), etc.) corresponds to different log levels.

  • Constructor: Configures different log transports (console, file, daily rotation) depending on the environment. In production, we log to files and use daily rotation, whereas in development, logging is done in the console.
  • Transports:
  • Console: Logs to the console when the app runs in a development environment.
  • File: Writes error logs to a specific file in production.
  • DailyRotateFile: Rotates application logs daily, compresses them, and limits the size and number of logs to keep them manageable.
  • Log Levels: Custom log levels are set up based on the environment. Development may use more detailed levels like trace, while production focuses on higher-level logs like info or error.
Raising Notices (RAISE_NOTICE function)

This utility function is designed to raise logs based on the type of notice:

  • RAISE_NOTICE accepts two parameters: type (the log level) and message (the log content). It uses the logger methods to log messages appropriately based on the type (error, warn, fatal, etc.).
  • It simplifies logging throughout the application by allowing the developer to call RAISE_NOTICE("error", "An error occurred") instead of directly interacting with the logger instance.
Code Walkthrough
Logger Setup (Logger.ts)
import { customLevels } from "@constants"; // custom log levels
import winston, { format, Logform } from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import process from "process"; // to check environment variables

// Formatter for log messages: colorize, timestamp, format log content
const formatter = winston.format.combine(
  winston.format.colorize(),
  winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
  winston.format.splat(),
  format.printf((info: Logform.TransformableInfo) => {
    const { timestamp, level, message, ...meta } = info;
    const formattedMessage = 
      typeof message === "string"
        ? message
        : typeof message === "object" || Array.isArray(message)
          ? JSON.stringify(message)
          : String(message);

    return `${timestamp} [${level}]: ${formattedMessage} ${Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ""}`;
  }),
);

// Helper function to check if in development environment
const isDevEnvironment = () => process.env.NODE_ENV === "development" || process.env.NODE_ENV === "local";

class Logger {
  private logger: winston.Logger;

  constructor() {
    const transports: winston.transport[] = [
      new winston.transports.Console({
        format: formatter, // Use our custom formatter
        level: isDevEnvironment() ? "trace" : "info", // Set logging level
      }),
    ];

    // Add file transports in production only
    if (!isDevEnvironment()) {
      transports.push(
        new winston.transports.File({
          filename: "logs/error.log",
          level: "error", // Log errors into separate file
          format: formatter,
        }),
        new DailyRotateFile({
          filename: "logs/application-%DATE%.log", // Rotates logs daily
          datePattern: "YYYY-MM-DD",
          zippedArchive: true, // Compress old logs
          maxSize: "20m",
          maxFiles: "14d", // Keep logs for 14 days
          format: formatter,
        }),
      );
    }

    // Create a Winston logger instance with our custom settings
    this.logger = winston.createLogger({
      level: isDevEnvironment() ? "trace" : "info",
      levels: customLevels.levels, // Apply custom levels
      transports, // Use defined transports
    });

    winston.addColors(customLevels.colors); // Add color coding for log levels
  }

  // Log at different levels
  trace(msg: string, meta?: object): void {
    this.logger.log("trace", msg, meta);
  }
  debug(msg: string, meta?: object): void {
    this.logger.debug(msg, meta);
  }
  info(msg: string, meta?: object): void {
    this.logger.info(msg, meta);
  }
  warn(msg: string, meta?: object): void {
    this.logger.warn(msg, meta);
  }
  error(msg: string, meta?: object): void {
    this.logger.error(msg, meta);
  }
  fatal(msg: string, meta?: object): void {
    this.logger.log("fatal", msg, meta);
  }
}

// Export logger instance for use across the app
const logger = new Logger();
export default logger;
RAISE_NOTICE Utility
import logger from "./Logger";

// Raise notices based on type and message
const RAISE_NOTICE = (type: string, message: string | any) => {
  if (type === "error") {
    logger.error(`${typeof message === "string" ? message : ""}`, typeof message !== "string" ? message : "");
    return;
  }
  if (type === "warn") {
    logger.warn(`${typeof message === "string" ? message : ""}`, typeof message !== "string" ? message : "");
    return;
  }
  if (type === "fatal") {
    logger.fatal(`${typeof message === "string" ? message : ""}`, typeof message !== "string" ? message : "");
    return;
  }
  logger.info(`${typeof message === "string" ? message : ""}`, typeof message !== "string" ? message : "");
};
export default RAISE_NOTICE;
Why We Need This Logging System
  1. Custom Log Levels: In complex applications, having more than default log levels is essential for organizing logs. For instance, trace helps developers trace execution in a development environment, while fatal is reserved for critical issues that might crash the application.
  2. Daily Log Rotation: Helps in log management, especially in production, where logs can grow large quickly. Rotating logs by date ensures that older logs are archived and compressed, preventing them from taking up unnecessary space.
  3. Separate Environment Logging: Development and production environments have different logging needs. In production, logging more details could expose sensitive information or slow down performance, so logging is restricted to important events (e.g., errors). In development, verbose logging (trace, debug) helps developers diagnose issues more easily.
  4. RAISE_NOTICE: Simplifies the logging process across the application. Instead of calling different logger methods (logger.info, logger.error, etc.), we can just call RAISE_NOTICE with the appropriate type, making the code cleaner and easier to maintain.

Github repository link for complete typescript backend boilerplate with winston logging: https://github.com/usamaamjid/typescript-backend-boilerplate.git

Conclusion

This blog walked you through setting up a TypeScript backend boilerplate from scratch. It covered organizing your project, setting up environments, implementing Winston logging, and configuring routes and controllers. This structure is scalable, maintainable, and provides a great starting point for building real-world applications.

With this TypeScript boilerplate, you're now ready to build robust, maintainable, and scalable applications!

Happy Coding :)