Building a Production-Ready Node.js + TypeScript Backend
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 commitdist/
Compiled output → source of truth issrc/.env
Secrets → never leak credentialslogs/
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:
Downloads Node 18 binaries
Stores them in
~/.nvmUpdates 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:
Reads
.tsfiles fromsrcType-checks everything
Emits
.jsfiles intodistStops 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
processis
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)
| Tool | Purpose |
| Prettier | Formatting |
| ESLint | Logic & 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:
Husky intercepts
Runs your commands
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.



