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.
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
- Connection Pooling: Always tune your database connection pool limits. A microservice container should never open 100 simultaneous connections.
- Compression & Rate Limiting: Enable
compressionmiddleware and protect sensitive routes with token buckets. - Structured Logging: Replace
console.logwith 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!