Developing a REST API with Express.js and TypeScript: A Practical Guide
express-js typescript rest api middleware

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

D. Rout

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. errorHandler must 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 hardeninghelmet() 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 app export makes this straightforward.
  • Docker — containerise the app with a multi-stage Dockerfile for lean production images.

Further Learning


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.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!