Building Scalable APIs with Node.js and TypeScript in 2025
Best practices for building production-ready, scalable REST and GraphQL APIs using Node.js, TypeScript, and modern tooling — from architecture to deployment.
There's a pattern we see constantly: a team builds their first Node.js API, everything works at 100 users, and then at 10,000 users the codebase becomes a maintenance problem. Not because Node.js can't handle the load — it absolutely can — but because the initial architecture didn't account for growth. Here's the structure we use for production APIs.
Project Structure That Scales
The architecture that's served us well across multiple projects follows a clear separation of concerns:
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
Ready to Work With a Software Development Agency That Delivers?
Get a free consultation and project estimate within 24 hours. No fluff — just an honest conversation about your goals, timeline, and budget.