Chapter 48: Node.js RESTful API
RESTful APIs with Node.js (2025–2026 style).
We will build everything from scratch — step by step — like I am sitting next to you right now:
- I open the terminal
- I create files one by one
- I explain every decision, why we do it this way, what alternatives exist, what most people do wrong, and what experienced developers actually do in production
We are going to create a realistic, modern, production-ready RESTful API for a Task Management application.
What we are building
A complete RESTful Task API with:
- User registration & login (JWT authentication)
- CRUD operations on personal tasks
- Input validation (Zod)
- Proper error handling
- TypeScript
- ESM (modern JavaScript modules)
- Environment variables + validation
- Logging
- Rate limiting
- Security headers
- Graceful shutdown
- Linting & formatting
This is the kind of foundation many real companies start with in 2025–2026.
Step 1 – Project Initialization (realistic setup)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# 1. Create folder mkdir task-api-rest cd task-api-rest # 2. Initialize npm + set ESM (very important) npm init -y npm pkg set type=module # 3. Install runtime dependencies npm install \ express \ cors \ helmet \ compression \ dotenv \ zod \ jsonwebtoken \ bcryptjs \ express-rate-limit # 4. Install TypeScript & dev tools npm install -D \ typescript \ @types/node \ @types/express \ @types/cors \ @types/jsonwebtoken \ @types/bcryptjs \ tsx \ nodemon \ eslint \ prettier \ eslint-config-standard-with-typescript \ @typescript-eslint/parser \ @typescript-eslint/eslint-plugin \ lint-staged \ husky |
package.json scripts (modern & practical)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
"scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write .", "typecheck": "tsc --noEmit", "prepare": "husky install" } |
Step 2 – Folder structure (what most serious teams use)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
task-api-rest/ ├── src/ │ ├── config/ ← env + constants │ │ ├── env.ts │ │ └── index.ts │ ├── controllers/ ← HTTP handlers (thin – call services) │ │ └── task.controller.ts │ ├── middleware/ ← reusable middleware │ │ ├── auth.middleware.ts │ │ ├── error.middleware.ts │ │ ├── rate-limit.middleware.ts │ │ └── validate.middleware.ts │ ├── routes/ ← route definitions │ │ └── task.routes.ts │ ├── schemas/ ← Zod schemas / DTOs │ │ └── task.schema.ts │ ├── services/ ← business logic (core) │ │ └── task.service.ts │ ├── types/ ← shared types │ │ └── index.ts │ └── index.ts ← entry point ├── .env ├── .env.example ├── tsconfig.json ├── package.json └── README.md |
Why this structure?
- Controllers are thin → only HTTP concerns
- Services are fat → contain real business logic (easy to test)
- Schemas are separate → reusable for validation & OpenAPI later
- Middleware is reusable → auth, validation, rate-limit, etc.
- Very easy to grow (add users, auth, etc.)
Step 3 – Environment + Zod validation (very important safety net)
src/config/env.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { z } from 'zod' import 'dotenv/config' const envSchema = z.object({ PORT: z.coerce.number().default(5000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'), RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000), // 15 minutes RATE_LIMIT_MAX: z.coerce.number().default(100) }) const env = envSchema.parse(process.env) export default env |
Why this pattern?
- Runtime validation (TypeScript types are compile-time only)
- Immediate crash if .env is wrong → fail fast
- Automatic type inference (env.JWT_SECRET is string)
Step 4 – Custom error handling (production must-have)
src/middleware/error.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import { Request, Response, NextFunction } from 'express' import env from '../config/env.js' export class AppError extends Error { constructor( public statusCode: number, message: string, public isOperational = true ) { super(message) this.name = 'AppError' } } export function errorHandler( err: Error, req: Request, res: Response, next: NextFunction ) { if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, message: err.message, ...(env.NODE_ENV !== 'production' && { stack: err.stack }) }) } // Unknown / programming error console.error('Unhandled error:', err) return res.status(500).json({ success: false, message: 'Internal server error' }) } |
Step 5 – JWT Authentication middleware (very common)
src/middleware/auth.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import { Request, Response, NextFunction } from 'express' import jwt from 'jsonwebtoken' import { AppError } from './error.middleware.js' import env from '../config/env.js' interface AuthPayload { userId: string role: string } declare global { namespace Express { interface Request { user?: AuthPayload } } } export function authenticate(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new AppError(401, 'No token provided') } const token = authHeader.split(' ')[1] try { const decoded = jwt.verify(token, env.JWT_SECRET) as AuthPayload req.user = decoded next() } catch (err) { throw new AppError(401, 'Invalid or expired token') } } |
Step 6 – Validation middleware with Zod
src/middleware/validate.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { Request, Response, NextFunction } from 'express' import { AnyZodObject } from 'zod' import { AppError } from './error.middleware.js' export function validateBody(schema: AnyZodObject) { return async (req: Request, res: Response, next: NextFunction) => { try { req.body = await schema.parseAsync(req.body) next() } catch (err) { next(new AppError(400, 'Validation failed')) } } } |
Step 7 – Rate limiting middleware (protect against abuse)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// src/middleware/rate-limit.middleware.ts import rateLimit from 'express-rate-limit' import env from '../config/env.js' export const apiLimiter = rateLimit({ windowMs: env.RATE_LIMIT_WINDOW_MS, max: env.RATE_LIMIT_MAX, message: { success: false, message: 'Too many requests, please try again later' }, standardHeaders: true, legacyHeaders: false }) |
Step 8 – Putting it all together (realistic Express app)
src/index.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import express, { Request, Response, NextFunction } from 'express' import cors from 'cors' import helmet from 'helmet' import compression from 'compression' import env from './config/env.js' import { requestLogger } from './middleware/logger.middleware.js' import { errorHandler } from './middleware/error.middleware.js' import { apiLimiter } from './middleware/rate-limit.middleware.js' import taskRoutes from './routes/task.routes.js' const app = express() // Global middleware app.use(helmet()) app.use(compression()) app.use(cors()) app.use(express.json()) // Logging app.use(requestLogger) // Rate limiting on all API routes app.use('/api', apiLimiter) // Routes app.use('/api/tasks', taskRoutes) // Health check app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', uptime: process.uptime(), environment: env.NODE_ENV }) }) // Error handler – MUST be last app.use(errorHandler) app.listen(env.PORT, () => { console.log(`🚀 Server running → http://localhost:${env.PORT}`) console.log(`Environment: ${env.NODE_ENV}`) }) |
Summary – What you now have
You have a modern, secure, type-safe Express API foundation:
- ESM + TypeScript
- Zod runtime validation
- Custom AppError + global error handler
- JWT authentication middleware
- Rate limiting
- Security headers & compression
- Logging
- Environment validation
This structure is used (with small variations) by many real teams building Express-based Node.js APIs today.
Which direction would you like to go next?
- Add JWT registration & login endpoints
- Connect real database (Prisma or Drizzle)
- Implement pagination, filtering, sorting for tasks
- Add unit & integration tests with Vitest
- Docker + production deployment checklist
- Migrate this project to Fastify or Hono (side-by-side comparison)
Just tell me what you want to build or understand next — I’ll continue with complete, realistic code and explanations. 😊
