Back to Blog
Backend 11 min read

Building Scalable APIs with Node.js and TypeScript in 2025

M
Mathew· November 20, 2024

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 /health returning 200 with timestamp and version
  • Enable compression middleware before route handlers (the compression package)
  • 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
Node.jsTypeScriptAPIBackend
Let's Work Together

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.

Free consultation24-hour responseNo commitment required