As a frontend engineer who has built backend services at BYJU'S and for personal projects, I know the mental shift from frontend to backend isn't trivial. Here's what you need to know to build your first production-quality Node.js backend. If this mental-model shift lands, the same lens runs through my TypeScript utility types guide and this deep dive on cutting Node.js memory in half. See also Frontend Testing Strategies That Actually Work in 2026.
The Mindset Shift
Frontend and backend engineering have different concerns:
| Frontend | Backend |
|---|---|
| User experience | Data integrity |
| Render performance | Throughput and latency |
| Client state | Database state |
| Browser APIs | OS and network APIs |
| Graceful degradation | Error handling and retries |
The biggest adjustment: on the backend, data is the product. A UI bug is annoying; a data corruption bug can be catastrophic.
Project Setup with TypeScript
mkdir my-api && cd my-api
pnpm init
pnpm add express cors helmet
pnpm add -D typescript @types/express @types/node @types/cors tsx
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Basic Express Server
// src/index.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(helmet()); // Security headers
app.use(cors()); // CORS for frontend
app.use(express.json()); // Parse JSON bodies
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Run with: pnpm tsx watch src/index.ts
Building REST Endpoints
Structure your routes by resource:
// src/routes/users.ts
import { Router } from 'express';
const router = Router();
interface User {
id: string;
name: string;
email: string;
}
// In-memory store (replace with database)
const users: User[] = [];
// GET /api/users
router.get('/', (req, res) => {
res.json(users);
});
// GET /api/users/:id
router.get('/:id', (req, res) => {
const user = users.find((u) => u.id === req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /api/users
router.post('/', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
const user: User = {
id: crypto.randomUUID(),
name,
email,
};
users.push(user);
res.status(201).json(user);
});
// DELETE /api/users/:id
router.delete('/:id', (req, res) => {
const index = users.findIndex((u) => u.id === req.params.id);
if (index === -1) return res.status(404).json({ error: 'User not found' });
users.splice(index, 1);
res.status(204).send();
});
export default router;
Register routes in your main file:
// src/index.ts
import userRoutes from './routes/users';
app.use('/api/users', userRoutes);
Middleware: The Backend Equivalent of HOCs
Middleware in Express is like higher-order components in React — they wrap your handlers with additional behavior.
Error Handling Middleware
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
class AppError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
}
}
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
}
export { AppError, errorHandler };
Request Logging Middleware
// src/middleware/logger.ts
import { Request, Response, NextFunction } from 'express';
function logger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
}
export { logger };
Database Basics with MongoDB
// src/db.ts
import { MongoClient, Db } from 'mongodb';
let db: Db;
async function connectDB() {
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
await client.connect();
db = client.db('myapp');
console.log('Connected to MongoDB');
}
function getDB(): Db {
if (!db) throw new Error('Database not connected');
return db;
}
export { connectDB, getDB };
Update your user route to use the database:
// GET /api/users
router.get('/', async (req, res) => {
const users = await getDB().collection('users').find().toArray();
res.json(users);
});
Authentication: JWT Basics
// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-in-production';
interface AuthRequest extends Request {
userId?: string;
}
function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
req.userId = decoded.userId;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
function generateToken(userId: string): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
}
export { authenticate, generateToken, AuthRequest };
Environment Variables
// src/config.ts
import { z } from 'zod';
const envSchema = z.object({
PORT: z.string().default('3001'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
MONGODB_URI: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const config = envSchema.parse(process.env);
Using Zod for environment validation catches missing variables at startup instead of at runtime when a request hits the missing value.
Error Handling Patterns
// Wrap async route handlers to catch promise rejections
function asyncHandler(fn: Function) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// Usage
router.get('/:id', asyncHandler(async (req, res) => {
const user = await getDB().collection('users').findOne({ _id: req.params.id });
if (!user) throw new AppError(404, 'User not found');
res.json(user);
}));
Project Structure
src/
├── index.ts # Entry point, server setup
├── config.ts # Environment variables
├── db.ts # Database connection
├── routes/
│ ├── users.ts # User endpoints
│ └── auth.ts # Auth endpoints
├── middleware/
│ ├── auth.ts # JWT authentication
│ ├── logger.ts # Request logging
│ └── errorHandler.ts
└── types/
└── index.ts # Shared TypeScript types
Key Takeaways
- Start with Express + TypeScript — it's the most transferable backend skill
- Think about data integrity first, then performance
- Middleware is your primary tool for cross-cutting concerns (auth, logging, errors)
- Validate everything at the boundary (request body, env vars, query params)
- Use async/await with proper error handling — unhandled rejections crash Node processes
- Keep your project structure flat and organized by feature, not by type
- Learn SQL basics even if you start with MongoDB — most companies use relational databases
Originally published at umesh-malik.com
Keep reading on umesh-malik.com:
Top comments (0)