Skip to main content

Command Palette

Search for a command to run...

Building a Production-Ready Node.js + TypeScript Backend

Updated
5 min readView as Markdown

A Deep, Code-First, Professional Guide

This article is not a checklist.
It’s a guided walkthrough, where we read code together and understand why it exists.

If you copy-paste without understanding you’ll forget it.
If you understand why you’ll never need to memorize.

Let’s do this the right way.


1. Git Setup — Why It Comes Before Code

git init

This single command creates a hidden .git directory.

What’s Inside .git?

  • Object database (your entire history)

  • Branch pointers

  • Commit references

  • Hooks (we’ll use these later)

Once this exists, your project has memory.


.gitignore — Preventing Silent Damage

Here’s an example of what a Node .gitignore looks like:

node_modules/
dist/
.env
logs/

Why Each Line Matters

  • node_modules/
    Can be regenerated at any time → never commit

  • dist/
    Compiled output → source of truth is src/

  • .env
    Secrets → never leak credentials

  • logs/
    Runtime artifacts → environment-specific

This file protects your repository from accidental disasters.


2. Node Version Manager — Solving a Real Production Problem

When you run:

nvm install 18

NVM:

  1. Downloads Node 18 binaries

  2. Stores them in ~/.nvm

  3. Updates your shell PATH

When you later run:

nvm use 18

Your terminal switches binaries, not installations.


.nvmrc in Action

18

Now this command:

nvm install

Reads .nvmrc and installs the correct version automatically.

CI pipelines rely on this behavior.


3. Initializing the Project

npm init -y

Generates:

{
  "name": "project-name",
  "version": "1.0.0",
  "scripts": {}
}

Why scripts Is Important

Later, we’ll run:

npm run lint
npm run test
npm run build

package.json becomes the control panel of your project.


4. TypeScript — Understanding What Actually Happens

Installing TypeScript

npm i -D typescript

This installs the TypeScript compiler, not runtime code.

Key Insight (Very Important)

Node.js never runs TypeScript
Node.js runs JavaScript only

TypeScript exists before runtime.


tsconfig.json Explained

After:

npx tsc --init

You configure:

{
  "rootDir": "./src",
  "outDir": "./dist"
}

What Happens Internally?

When you run:

npx tsc

TypeScript:

  1. Reads .ts files from src

  2. Type-checks everything

  3. Emits .js files into dist

  4. Stops if types are broken

Your server always runs dist, never src.


Why @types/node Is Required

npm i -D @types/node

Without this:

process.env.PORT

TypeScript says:

I don’t know what process is

Because Node is written in JavaScript, not TypeScript.
@types/node teaches TypeScript how Node works.


5. Prettier — Code Formatting as a Machine Rule

Install:

npm i -D prettier

Create config:

{
  "singleQuote": false,
  "semi": true
}

What Prettier Actually Does

Prettier:

  • Parses your code into an AST

  • Reprints it using fixed rules

  • Ignores personal preferences

Example:

const x='hello'

Becomes:

const x = "hello";

No discussion. No arguments.


Why Prettier Runs in CI

npx prettier . --check

This command:

  • Does NOT change files

  • Fails if formatting is wrong

Perfect for automation.


6. ESLint — Understanding Code Behavior

Install:

npm i -D eslint @eslint/js typescript-eslint

ESLint vs Prettier (Critical Difference)

ToolPurpose
PrettierFormatting
ESLintLogic & correctness

Example ESLint catches:

async function test() {
  fetchData(); // missing await
}

Prettier cannot catch this. ESLint can.


7. Git Hooks — Enforcing Quality Automatically

Install Husky:

npx husky init

Creates:

.husky/
  pre-commit

What pre-commit Does

Before Git creates a commit:

  1. Husky intercepts

  2. Runs your commands

  3. Fails commit if checks fail


Why lint-staged Is Necessary

Without it:

  • ESLint runs on entire repo

  • Commits become slow

With this config:

"lint-staged": {
  "*.ts": [
    "npm run format:check",
    "npm run lint:check"
  ]
}

Only changed files are checked.

Fast. Clean. Scalable.


8. Configuration Layer — Clean Architecture in Practice

Bad Pattern (Avoid)

process.env.PORT
process.env.DB_URL

Scattered everywhere → impossible to refactor.


Good Pattern (Your Code)

import dotenv from "dotenv";
dotenv.config();

const Config = {
  PORT: Number(process.env.PORT) || 3000,
  NODE_ENV: process.env.NODE_ENV ?? "development",
};

export default Config;

Now the rest of the app does:

Config.PORT

If tomorrow config comes from JSON or Vault — no app code changes.


9. Express App vs Server — Explained with Code

app.ts

import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello World");
});

export default app;

This file:

  • Defines routes

  • Adds middleware

  • Exports Express instance

No networking here.


server.ts

import app from "./app.js";
import Config from "./config/index.js";

app.listen(Config.PORT, () => {
  console.log(`Server running on ${Config.PORT}`);
});

Now:

  • App is reusable

  • Tests can import app

  • Server lifecycle is controlled


10. Winston Logger — Real Logging Explained

import winston from "winston";

Key Concepts

  • Transport → where logs go

  • Level → severity

  • Format → structure

Your config:

new winston.transports.File({
  filename: "error.log",
  level: "error",
})

Means:

Only errors go here nothing else

This is production-grade logging.


11. Error Handling — Express Internals

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json(...)
});

Why 4 Parameters Matter

Express checks function length.
4 params = error middleware
3 params = normal middleware

This is not optional.


12. Testing — Code Confidence

import request from "supertest";

const response = await request(app).get("/");

Supertest:

  • Injects requests directly into Express

  • No real server

  • No ports

  • Very fast

This is how real backend tests are written.