
Developing a REST API with Express.js and TypeScript: A Practical Guide

D. Rout
April 6, 2026 10 min read
On this page
Express.js has been the go-to framework for building REST APIs in Node.js for over a decade — and for good reason. It's minimal, flexible, and battle-tested. But most "getting started" tutorials stop at Hello World. This post goes further.
We'll build a production-ready REST API template using Express.js and TypeScript, covering everything a real application needs: middleware setup, request validation, JWT authentication, response caching, rate limiting, and centralized error handling — all wired together in a clean, extensible structure.
📦 The full source code is available on Github Clone it, run
npm install && npm run dev, and follow along.
Project Structure
Before writing any code, let's agree on a clean folder layout. A flat index.ts file works for demos — it won't survive a real project.
src/
├── app.ts # Express app setup & middleware wiring
├── index.ts # Server entry point
├── middleware/
│ ├── authenticate.ts # JWT verification middleware
│ ├── errorHandler.ts # Centralized error handler + AppError class
│ ├── notFoundHandler.ts
│ ├── rateLimiter.ts
│ ├── requestId.ts
│ └── validate.ts # express-validator error middleware
├── routes/
│ ├── auth.routes.ts # Login endpoint
│ └── product.routes.ts
└── utils/
├── cache.ts # node-cache wrapper + cacheMiddleware
├── jwt.ts # sign/verify helpers
└── logger.ts # Winston logger
Dependencies
npm install express express-rate-limit express-validator jsonwebtoken \
node-cache dotenv helmet cors morgan winston uuid
npm install -D @types/express @types/jsonwebtoken @types/cors \
@types/morgan @types/node @types/uuid typescript ts-node-dev
Key packages at a glance:
- helmet — sets security-related HTTP response headers
- cors — enables cross-origin resource sharing
- morgan — HTTP request logging
- express-validator — declarative request validation
- jsonwebtoken — JWT sign and verify
- node-cache — fast in-process key-value cache
- express-rate-limit — sliding window rate limiter
- winston — structured logging with transport support
1. App Bootstrap (src/app.ts)
This is where everything comes together. Middleware is applied in order — security headers first, then parsing, then logging, then your custom layers, then routes, and finally error handlers at the very end.
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { globalRateLimiter } from './middleware/rateLimiter';
import { requestIdMiddleware } from './middleware/requestId';
import { errorHandler } from './middleware/errorHandler';
import { notFoundHandler } from './middleware/notFoundHandler';
import authRoutes from './routes/auth.routes';
import productRoutes from './routes/product.routes';
const app: Application = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan('combined'));
app.use(requestIdMiddleware); // stamps every request with a UUID
app.use(globalRateLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Must be last:
app.use(notFoundHandler);
app.use(errorHandler);
export default app;
⚠️ Order matters.
errorHandlermust be registered after all routes, and it must accept four arguments(err, req, res, next)— Express detects it as an error handler by its arity.
2. Request ID Tracking
Tracing a request through logs is painful without a stable identifier. This middleware stamps every incoming request with a UUID and echoes it back in the response header.
// src/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
const id = (req.headers['x-request-id'] as string) || uuidv4();
req.headers['x-request-id'] = id;
res.setHeader('X-Request-Id', id);
next();
}
Clients can pass their own X-Request-Id (useful for distributed tracing), or one is generated automatically.
3. Input Validation
Never trust request data. We use express-validator to define rules declaratively and a shared validate middleware to run them.
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { validationResult } from 'express-validator';
import { AppError } from './errorHandler';
export function validate(req: Request, _res: Response, next: NextFunction): void {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const messages = errors.array().map((e) => e.msg).join(', ');
return next(new AppError(messages, 422));
}
next();
}
In your route, you chain validation rules before the validate middleware:
import { body } from 'express-validator';
const loginValidation = [
body('email').isEmail().withMessage('A valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
];
router.post('/login', authRateLimiter, loginValidation, validate, handler);
If validation fails, validate calls next(new AppError(...)) — which routes to our centralized error handler instead of crashing.
4. JWT Authentication
Token-based auth is the standard for stateless REST APIs. We sign tokens on login and verify them on protected routes.
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'changeme';
const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1h';
export interface JwtPayload {
userId: string;
email: string;
role: string;
}
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN } as jwt.SignOptions);
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, SECRET) as JwtPayload;
}
The authenticate middleware extracts the Bearer token from the Authorization header and attaches the decoded payload to req.user:
// src/middleware/authenticate.ts
import { Response, NextFunction } from 'express';
import { verifyToken, JwtPayload } from '../utils/jwt';
import { AppError } from './errorHandler';
import { Request } from 'express';
export interface AuthRequest extends Request {
user?: JwtPayload;
}
export function authenticate(req: AuthRequest, _res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new AppError('Missing or invalid Authorization header', 401));
}
try {
req.user = verifyToken(authHeader.split(' ')[1]);
next();
} catch {
next(new AppError('Invalid or expired token', 401));
}
}
Protecting a route is then a one-liner:
router.post('/', authenticate, validate, handler);
5. Response Caching
For read-heavy endpoints, an in-process cache dramatically reduces latency and backend load. node-cache is a zero-dependency, TTL-based key-value store.
// src/utils/cache.ts
import NodeCache from 'node-cache';
import { Request, Response, NextFunction } from 'express';
const ttl = parseInt(process.env.CACHE_TTL_SECONDS || '60', 10);
export const cache = new NodeCache({ stdTTL: ttl });
export function cacheMiddleware(key: string) {
return (req: Request, res: Response, next: NextFunction) => {
const cacheKey = `${key}:${req.url}`;
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.json(cached);
}
res.setHeader('X-Cache', 'MISS');
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
cache.set(cacheKey, body);
return originalJson(body);
};
next();
};
}
The trick here is monkey-patching res.json — we intercept the response on its way out, store it in the cache, then forward it normally. Consumers see an X-Cache: HIT or MISS header for observability.
Usage in a route:
router.get('/', cacheMiddleware('products'), handler);
6. Rate Limiting
Rate limiting protects your API from abuse and brute-force attacks. We apply two limiters: a global one for all routes and a stricter one on auth endpoints.
// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
export const globalRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, message: 'Too many requests, please try again later.' },
});
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // max 10 login attempts per window
message: { success: false, message: 'Too many auth attempts.' },
});
standardHeaders: true adds RateLimit-* headers per the IETF draft spec, giving clients visibility into their remaining quota.
7. Centralized Error Handling
Scattered res.status(500).json(...) calls are a maintenance nightmare. Instead, we use a custom AppError class and a single Express error handler that processes everything in one place.
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
export class AppError extends Error {
public statusCode: number;
public isOperational: boolean;
constructor(message: string, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
}
}
export function errorHandler(
err: Error | AppError,
req: Request,
res: Response,
_next: NextFunction
): void {
const requestId = req.headers['x-request-id'];
const statusCode = err instanceof AppError ? err.statusCode : 500;
const message =
err instanceof AppError && err.isOperational
? err.message
: 'Internal Server Error';
logger.error(`[${requestId}] ${err.message}`);
res.status(statusCode).json({
success: false,
message,
requestId,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
}
The isOperational flag distinguishes expected errors (validation failures, 404s) from unexpected ones (programmer bugs, unhandled exceptions). In production, unexpected errors return a generic message to avoid leaking internals.
From anywhere in your code, throwing an error is now:
throw new AppError('Product not found', 404);
// or
return next(new AppError('Forbidden', 403));
8. Putting It Together — The Products Route
Here's the full products route showing all the pieces working together: validation, caching, authentication, and role-based access.
// src/routes/product.routes.ts (abbreviated)
// GET /api/products — public, cached
router.get('/', cacheMiddleware('products'), (_req, res) => {
res.json({ success: true, data: products });
});
// POST /api/products — requires valid JWT
router.post('/', authenticate, createProductValidation, validate, (req: AuthRequest, res) => {
const { name, price } = req.body;
const newProduct = { id: String(Date.now()), name, price, createdBy: req.user!.email };
products.push(newProduct);
res.status(201).json({ success: true, data: newProduct });
});
// DELETE /api/products/:id — requires JWT + admin role
router.delete('/:id', authenticate, (req: AuthRequest, res, next) => {
if (req.user!.role !== 'admin') return next(new AppError('Forbidden: admins only', 403));
// ...
});
The req.user!.email access is safe because authenticate runs first and will short-circuit with a 401 before the handler is reached.
Testing the API
With the server running (npm run dev), try these requests:
Login and get a token:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
List products (watch X-Cache header on second call):
curl http://localhost:3000/api/products -v
Create a product (authenticated):
curl -X POST http://localhost:3000/api/products \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Widget C","price":29.99}'
Delete a product (admin only):
# First login as admin@example.com to get an admin token
curl -X DELETE http://localhost:3000/api/products/1 \
-H "Authorization: Bearer <admin-token>"
Environment Variables
Copy .env.example to .env and configure:
PORT=3000
NODE_ENV=development
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=1h
CACHE_TTL_SECONDS=60
Never commit .env to source control. Use a secrets manager (AWS Secrets Manager, Doppler, Vault) in production.
What's Next?
This template gives you a solid foundation. Here's where to take it next:
- Replace the in-memory store with a real database. For MongoDB with Mongoose, the connection and model layer slot in cleanly under
src/models/. - Add refresh tokens — JWTs are short-lived by design. A refresh token flow (
/api/auth/refresh) using httpOnly cookies is the standard complement. - Helmet hardening —
helmet()applies sensible defaults. For stricter Content Security Policy configuration, see the Helmet documentation. - OpenAPI / Swagger — document your API with swagger-ui-express using JSDoc-style annotations.
- Testing — add Jest with supertest for integration tests. The
appexport makes this straightforward. - Docker — containerise the app with a multi-stage
Dockerfilefor lean production images.
Further Learning
- Express.js Official Routing Guide
- TypeScript Handbook — Advanced Types
- express-validator Documentation
- jsonwebtoken on GitHub
- OWASP REST Security Cheat Sheet
- express-rate-limit on npm
Wrapping Up
A well-structured Express.js API isn't just about routing requests — it's about the invisible scaffolding that keeps it secure, observable, and maintainable as complexity grows. The patterns covered here — middleware composition, centralised error handling, validation pipelines, caching, and token auth — are the same ones used in production APIs serving millions of requests.
Clone the starter repo, swap the in-memory arrays for a real database, and you have a template that'll carry you a long way.
⭐ Star the repo on GitHub if it was useful — and feel free to open issues or PRs.
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!