Backend
Building Maintainable APIs with Node.js and TypeScript
Best practices for building production-ready, scalable REST and GraphQL APIs using Node.js, TypeScript, and modern tooling — from architecture to deployment.
By TeknoesisPublished Updated 11 min read
A familiar story: a team builds its first Node.js API, everything works at 100 users, and by 10,000 users the codebase is hard to change. Node.js can handle the load. The problem is usually an architecture that didn't plan for growth. Here's a structure that holds up.
Our own main API, for BrokLeads, is Laravel rather than Node, but it follows the same principles you'll see below: thin controllers, business rules in services, and one shared helper for search, filters and sorting.
Project Structure That Scales
Separate the concerns clearly:
src/
controllers/ # Route handlers — thin layer, just parse and respond
services/ # Business logic lives here
repositories/ # Database queries isolated here
middleware/ # Auth, validation, rate limiting, error handling
routes/ # Route definitions and grouping
types/ # TypeScript interfaces and DTOs
utils/ # Helpers, formatters, constants
config/ # Environment config, database setup
app.ts # Express app setup (no server.listen here)
server.ts # Imports app.ts and starts the server
The reason to separate app.ts from server.ts is testability. In your tests, you import app directly and use Supertest against it without starting a real server. This makes tests faster, avoids port conflicts in CI, and keeps your test setup clean.
TypeScript: Non-Negotiable
A strict TypeScript config prevents an entire category of runtime bugs and makes refactoring safe when your API grows. This tsconfig works well for production Node.js services:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"outDir": "dist",
"rootDir": "src"
}
}
noUncheckedIndexedAccess is the flag people skip and then regret. It makes array access return T | undefined instead of just T, which catches a surprising number of off-by-one bugs and missing index guards before they reach production.
Error Handling Done Right
Write a base error class and an async wrapper middleware. This pattern eliminates try/catch in every controller:
// utils/AppError.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
// middleware/asyncHandler.ts
import type { RequestHandler } from "express";
export const asyncHandler =
(fn: RequestHandler): RequestHandler =>
(req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Global error middleware — add LAST in app.ts
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error("Unhandled error:", err);
res.status(500).json({ error: "An unexpected error occurred" });
});
With this in place, your controllers just throw and forget:
export const getUser = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new AppError(404, "User not found");
res.json(user);
});
Authentication with JWT
Short-lived access tokens in memory, refresh tokens in httpOnly cookies. This pattern prevents XSS attacks from stealing credentials while still giving you a way to revoke sessions:
// Issue tokens on login
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Store refresh token in httpOnly cookie
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
Fifteen-minute access tokens mean a stolen token expires quickly. The refresh token in an httpOnly cookie can't be read by JavaScript, so XSS attacks can't steal it. To revoke a session, delete the refresh token from your database.
Rate Limiting
Use express-rate-limit with a Redis store in production. In-memory rate limiting doesn't work across multiple server instances:
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ client: redisClient }),
message: { error: "Too many requests — slow down." },
});
app.use("/api/", apiLimiter);
Validation with Zod
Zod is TypeScript-first and the inferred types are exactly what you need in your service layer:
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(100),
name: z.string().min(2).max(50),
});
type CreateUserInput = z.infer<typeof createUserSchema>;
// Validation middleware
const validate = (schema: ZodSchema) =>
asyncHandler(async (req, res, next) => {
req.body = await schema.parseAsync(req.body);
next();
});
The Repository Pattern
Keep database queries separate from business logic. This makes unit testing straightforward — mock the repository, test the service in isolation:
// repositories/userRepository.ts
export const userRepository = {
findById: (id: string) =>
db.user.findUnique({ where: { id } }),
findByEmail: (email: string) =>
db.user.findUnique({ where: { email } }),
create: (data: CreateUserInput & { password: string }) =>
db.user.create({ data }),
};
// services/userService.ts
export const userService = {
async registerUser(input: CreateUserInput) {
const existing = await userRepository.findByEmail(input.email);
if (existing) throw new AppError(409, "Email already registered");
const hashed = await bcrypt.hash(input.password, 12);
return userRepository.create({ ...input, password: hashed });
},
};
Production Deployment Checklist
- Set
NODE_ENV=production— Express disables detailed error stack traces in production mode - Add a health check route:
GET /healthreturning 200 with timestamp and version - Enable compression middleware before route handlers (the
compressionpackage) - Use structured logging — Winston or Pino, not
console.log - Run behind a load balancer or reverse proxy (Nginx, Caddy, or your cloud platform's ALB)
- Set up graceful shutdown to drain active connections before stopping the process
This is the kind of structure we start from when we build custom software and its APIs, adjusted to the load and to the team that will maintain it.