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 /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

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.

Want help with your own project?

A few lines is enough. We'll ask the right questions on the call.

  1. We reply within one working day

    By email or WhatsApp, whichever you prefer.

  2. A short call

    To understand the goal, the people who'll use it and any deadline.

  3. A written scope and estimate

    Before any work starts, so you know what you're agreeing to.

So we can reply on WhatsApp if you prefer.

What are you building, who is it for, and is there a deadline?

We reply within one working day. By sending this you agree that Teknoesis (Lahore, Pakistan) may contact you about your request and store your details as described in our Privacy Policy. We use analytics tools; see Your Privacy Choices in the footer.