Table of contents
To build a production-ready REST API with Express and TypeScript: set up a strict tsconfig.json, structure the project into routes, controllers, services, and a data-access layer instead of putting logic directly in route handlers, validate every request body at runtime with a schema library like Zod (TypeScript types alone don't protect you at runtime), authenticate with JWT verified in dedicated middleware, and centralize error handling in one Express error-handling middleware instead of scattering try/catch blocks across routes.
- TypeScript types are compile-time only โ you still need runtime validation (Zod/Joi) at the API boundary.
- Separate routes, controllers, services, and data access into distinct layers so each is independently testable.
- Authentication belongs in middleware, not duplicated inside every route handler.
- One centralized error-handling middleware keeps error responses consistent across the whole API.
- Async route handlers need explicit error forwarding โ an unhandled rejection in Express won't reach your error middleware on its own.
Express is still one of the most common ways to build a Node.js API in 2026 โ it's minimal, unopinionated, and has a huge middleware ecosystem. The problem most teams run into isn't Express itself, it's what happens as the API grows: untyped request bodies causing runtime crashes, authentication logic copy-pasted across routes, and error handling that's inconsistent from one endpoint to the next.
This guide walks through building an Express API the way it should scale: typed from the start, validated at the boundary, structured into clear layers, and with authentication and error handling centralized instead of repeated.
Why Express + TypeScript?
Plain JavaScript Express APIs work fine until the codebase grows past a handful of routes. At that point, a few recurring problems show up:
- Silent shape mismatches. A request body missing a field, or a service function called with arguments in the wrong order, fails at runtime instead of being caught while writing the code.
- Refactoring gets risky. Renaming a field on a shared type means manually finding every usage; TypeScript's compiler finds them for you.
- API contracts drift from documentation. Typed request/response interfaces double as living documentation that can't silently go stale the way a separate Markdown doc can.
TypeScript doesn't replace good API design โ it catches an entire category of bugs before they reach a code review, let alone production.
Project Setup
Start with a strict TypeScript configuration โ loose settings defeat much of the point of adopting TypeScript in the first place.
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
A typical scalable layout separates concerns from day one:
src/
routes/ # HTTP verb + path โ controller wiring only
controllers/ # request/response handling
services/ # business logic
repositories/ # database access
middleware/ # auth, validation, error handling
types/ # shared interfaces and DTOs
app.ts # Express app configuration
server.ts # entrypoint โ starts the HTTP server
Your First Typed Route
Express's own request/response types can be extended for stronger guarantees on params and body shape:
import { Router } from 'express';
import { getUserById } from '../controllers/users.controller';
const router = Router();
router.get('/users/:id', getUserById);
export default router;
import { Request, Response, NextFunction } from 'express';
import { findUserById } from '../services/users.service';
interface UserParams {
id: string;
}
export async function getUserById(
req: Request,
res: Response,
next: NextFunction
) {
try {
const user = await findUserById(req.params.id);
res.status(200).json(user);
} catch (err) {
next(err); // forward to centralized error handler
}
}
Notice the explicit next(err) call โ Express does not automatically catch rejected promises in async handlers, so forgetting this line means errors in async code silently hang the request instead of reaching your error middleware.
Middleware and Request Validation
TypeScript types disappear at runtime โ they give you zero protection against a real HTTP request with a malformed or malicious body. Validate at the boundary with a schema library, and derive your TypeScript types from that same schema so both stay in sync automatically:
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export function validateBody(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
req.body = result.data;
next();
};
}
Attach it to any route that accepts a body: router.post('/users', validateBody(createUserSchema), createUser). Malformed requests are rejected with a clear 400 before they ever reach your business logic.
JWT Authentication
Authentication belongs in middleware, verified once, rather than re-implemented inside every protected route:
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
interface AuthPayload {
userId: string;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing or invalid token' });
}
try {
const token = header.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_SECRET!) as AuthPayload;
next();
} catch {
res.status(401).json({ message: 'Invalid or expired token' });
}
}
Apply it per-route or per-router: router.get('/orders', requireAuth, getOrders). Downstream handlers can then trust req.user without re-verifying anything.
JWT_SECRET and other credentials in environment variables, never committed to git. Use a package like dotenv locally and your platform's secret manager in production.
Centralized Error Handling
Instead of a try/catch block with custom response formatting in every route, throw a typed error and handle it in one place:
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/AppError';
// registered LAST, after all routes
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ message: err.message });
}
console.error(err); // unexpected error โ log for investigation
res.status(500).json({ message: 'Internal server error' });
}
Now any layer of the app can throw new AppError(404, 'User not found') and it's formatted consistently everywhere, with unexpected errors still logged and returning a safe generic message instead of leaking internals.
Layered Architecture
Keeping routes, controllers, services, and data access separate pays off as the API grows:
| Layer | Responsibility | Should NOT contain |
|---|---|---|
| Routes | Map HTTP verb + path to a controller | Business logic, validation logic |
| Controllers | Parse request, call service, shape response | Database queries, business rules |
| Services | Business logic and orchestration | Express-specific req/res objects |
| Repositories | Database queries only | Business logic, validation |
The payoff: services and repositories can be unit tested without spinning up an HTTP server, and swapping a database library later only touches the repository layer.
Testing Your API
With services isolated from Express, most business logic can be tested directly. For the HTTP layer itself, supertest paired with your test runner covers request/response behavior end-to-end:
import request from 'supertest';
import { app } from '../src/app';
test('GET /users/:id returns 404 for unknown user', async () => {
const res = await request(app).get('/users/does-not-exist');
expect(res.status).toBe(404);
});
If your team is already automating API checks like this, our API automation testing guide covers taking that same pattern further into a full CI-gated regression suite.
Best Practices for Production
- Enable
strictmode in tsconfig from day one โ retrofitting it later on a large codebase is painful. - Validate every request body, query, and params object at runtime โ never trust TypeScript types alone.
- Keep authentication and authorization as middleware, not duplicated per-route logic.
- Register the error-handling middleware last, after all routes and other middleware.
- Never return raw error stack traces or internals to the client in production responses.
- Use environment variables for all secrets and configuration, validated at startup so misconfiguration fails fast.
- Add request logging (e.g.
pino-http) so production issues can be traced without redeploying.
Frequently Asked Questions
For anything beyond a weekend prototype, yes. TypeScript catches a large class of bugs โ wrong request body shapes, undefined property access, mismatched function signatures โ at compile time instead of in production, and it makes refactoring far safer as the API grows. The setup overhead is a few minutes with a starter tsconfig.
No. TypeScript types are erased at compile time and provide zero runtime protection against malformed input from an actual HTTP request. You need a runtime validation library like Zod or Joi at the API boundary; TypeScript then infers types from that same schema so you get both compile-time and runtime safety from one source of truth.
As middleware, not inside individual route handlers. A single authentication middleware verifies the token once and attaches the user to the request object; route handlers and further authorization middleware can then read that user without re-implementing token verification in every route.
Use a custom error class with an HTTP status code, throw it from anywhere in your route handlers or services, and catch it in one centralized Express error-handling middleware registered last. That keeps error formatting consistent and means individual routes don't need repetitive try/catch blocks for every failure path.
Express remains a strong choice for its simplicity, huge middleware ecosystem, and minimal opinions about project structure. Fastify offers better raw throughput and built-in schema validation; NestJS provides a more opinionated, Angular-style architecture for large teams. Express is usually the right pick when you want full control over structure without a steep learning curve.
Separate routes, controllers, services, and data-access code into distinct layers instead of putting logic directly in route handlers. Routes should only wire an HTTP verb and path to a controller; controllers handle request/response; services contain business logic; and a repository layer isolates database access, which also makes each layer independently testable.
Need help scaling an existing Express API โ or building one from scratch?
We design layered, typed API architectures and wire in the auth, validation, and testing to match.