DPI Computing SocietyDPI Computing SocietyLearn, build, and grow together
Tutorial

Building Scalable RESTful APIs with Node.js and TypeScript in 2026

A comprehensive production-grade blueprint for architecting modular, type-safe REST APIs using Node.js, Express, TypeScript, and modern best practices.

TTahmid Hasan
1 min read
Building Scalable RESTful APIs with Node.js and TypeScript in 2026

Introduction

Building backend services that survive high user concurrency requires more than just knowing how to route an HTTP request. In modern software engineering, type safety, separation of concerns, and predictable error boundaries are non-negotiable pillars.

In this full-scale tutorial, we will build a clean-architecture REST API from scratch using TypeScript and Node.js.


1. Project Architecture Overview

We advocate for the Controller-Service-Repository (CSR) layer pattern:

  • Routing Layer: Validates incoming request shapes and extracts query/path parameters.
  • Controller Layer: Orchestrates HTTP response codes, headers, and serialization.
  • Service Layer: Pure business logic without HTTP concepts.
  • Repository / Data Access Layer: Directly interacts with the database (ORM / SQL client).
Client Request
      │
      ▼
┌───────────────┐
│ Express Router│ ── (Schema Validation via Zod)
└───────┬───────┘
        ▼
┌───────────────┐
│  Controllers  │ ── (Status codes & HTTP Response)
└───────┬───────┘
        ▼
┌───────────────┐
│   Services    │ ── (Domain logic & Transactions)
└───────┬───────┘
        ▼
┌───────────────┐
│  Repository   │ ── (Prisma / Database queries)
└───────────────┘

2. Setting Up Strict TypeScript Configuration

A robust tsconfig.json eliminates entire classes of runtime exceptions before deployment:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

3. Implementing the Centralized Error Handler

Leaking stack traces or unstructured errors degrades client integration and compromises security. Here is how we define a standard domain error:

export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
    public readonly details: Record<string, unknown> | null = null
  ) {
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
    Error.captureStackTrace(this, this.constructor);
  }

  static badRequest(message: string, details?: Record<string, unknown>) {
    return new AppError(400, "BAD_REQUEST", message, details ?? null);
  }

  static notFound(message = "Resource not found") {
    return new AppError(404, "NOT_FOUND", message);
  }

  static internal(message = "An unexpected error occurred") {
    return new AppError(500, "INTERNAL_ERROR", message);
  }
}

And the Express global middleware:

import { Request, Response, NextFunction } from "express";

export function errorHandler(
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
  }

  console.error("Unhandled Exception:", err);
  return res.status(500).json({
    success: false,
    error: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error. Please try again later.",
    },
  });
}

4. Validating Requests with Schema Guards

Never trust user input. By pairing Zod with TypeScript, we achieve runtime validation with compile-time type inference:

import { z } from "zod";

export const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(50),
  role: z.enum(["MEMBER", "INSTRUCTOR"]).default("MEMBER"),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;

5. Performance Best Practices

  1. Connection Pooling: Always tune your database connection pool limits. A microservice container should never open 100 simultaneous connections.
  2. Compression & Rate Limiting: Enable compression middleware and protect sensitive routes with token buckets.
  3. Structured Logging: Replace console.log with Pino or Winston formatting in JSON for seamless ingestion into Datadog or Grafana Loki.

Conclusion

By enforcing the Controller-Service architecture, using strict TypeScript boundaries, and handling errors defensively, your API remains maintainable and ready for heavy production workloads. Happy coding!

Related posts