Back to blog
Node.js
Intermediate

Building a Scalable REST API with Node.js, Express, and TypeScript

This guide walks you through creating a production‑ready REST API using Node.js, Express, and TypeScript. You'll learn project setup, layered architecture, validation, authentication, testing, and deployment. By the end, you'll have a reusable codebase for any application.

September 26, 202519 min read

Introduction

Node.js has become the runtime of choice for building scalable network applications, and when combined with Express.js and TypeScript, developers gain a powerful stack for creating RESTful APIs that are both maintainable and type‑safe. This guide walks you through the entire process of designing, implementing, and deploying a production‑ready REST API using Node.js, Express, and TypeScript. We'll cover project setup, architectural patterns, middleware strategies, data validation, authentication, error handling, testing, and deployment considerations. By the end, you'll have a concrete codebase that you can extend for any domain — whether you're building a SaaS platform, an internal tool, or a micro‑service in a larger system.

Table of Contents

Core Concepts

We'll start by reviewing the foundational ideas that make a REST API effective. REST (Representational State Transfer) relies on stateless interactions and standard HTTP methods (GET, POST, PUT, DELETE) to operate on resources identified by URIs. Each request contains all the information needed to understand and process it, which enables horizontal scaling. TypeScript adds static typing to JavaScript, catching errors at compile time and providing richer IDE support. Express.js offers a minimalist, unopinionated middleware framework that simplifies routing, request handling, and response formatting. Together, these technologies let you build APIs that are explicit about contracts, easy to refactor, and resilient to change.

Architecture Overview

A clean, scalable API typically follows a layered architecture. The presentation layer (controllers) receives HTTP requests, validates input, and delegates work to the service layer. The service layer encapsulates business logic, coordinates calls to repositories, and handles transactions. The repository layer abstracts data access, whether you're using an ORM like TypeORM or a raw query builder. Data Transfer Objects (DTOs) shape the payloads that cross layer boundaries, ensuring that internal entities are never exposed directly. Middleware functions handle cross‑cutting concerns such as authentication, logging, rate limiting, and error wrapping. By keeping each layer focused, you achieve high cohesion and low coupling, making the system easier to test and extend.

Step-by-Step Guide

  1. Initialize the project
    mkdir my-api && cd my-apinpm init -ynpm i express typescript ts-node-dev @types/node @types/expressnpx tsc --init --rootDir src --outDir dist --esModuleInterop true --resolveJsonModule true --lib es6,dom --strict
  2. Create the source folder
    mkdir -p src/{controllers,services,repositories,dtos,middleware,utils}
  3. Set up the Express app (src/app.ts)
    import express, { Request, Response, NextFunction } from 'express';import helmet from 'helmet';import rateLimit from 'express-rate-limit';import userRouter from './routes/userRoutes';import { errorHandler } from './middleware/error.middleware';const app = express();// Security middlewareapp.use(helmet());app.use(express.json({ limit: '1mb' }));// Rate limiting: 100 requests per 15 minutes per IPconst limiter = rateLimit({  windowMs: 15 * 60 * 1000,  max: 100,  standardHeaders: true,  legacyHeaders: false});app.use(limiter);// Routesapp.use('/api/users', userRouter);// 404 handlerapp.use((req: Request, res: Response) => {  res.status(404).json({ message: 'Route not found' }));// Central error handlerapp.use(errorHandler);export default app;
  4. Define a basic route (src/routes/userRoutes.ts)
    import { Router } from 'express';import { getAllUsers, createUser, updateUser, deleteUser } from '../controllers/userController';const router = Router();router.get('/', getAllUsers);router.post('/', createUser);router.put('/:id', updateUser);router.delete('/:id', deleteUser);export default router;
  5. Wire routes in app.ts

    In app.ts after setting up middleware, add:

    import userRouter from './routes/userRoutes';app.use('/api/users', userRouter);
  6. Create a controller (src/controllers/userController.ts)
    import { Request, Response } from 'express';import { userService } from '../services/userService';import { CreateUserDto, UpdateUserDto } from '../dtos';import { validate } from 'class-validator';import { plainToInstance } from 'class-transformer';export const getAllUsers = async (req: Request, res: Response) => {  try {    const users = await userService.findAll();    res.json(users);  } catch (err) {    res.status(500).json({ message: 'Failed to fetch users' });  }};export const createUser = async (req: Request, res: Response) => {  try {    const dto = plainToInstance(CreateUserDto, req.body);    const errors = await validate(dto);    if (errors.length > 0) {      return res.status(400).json({ errors: errors.map(e => e.constraints) });    }    const user = await userService.create(dto);    res.status(201).json(user);  } catch (err) {    res.status(400).json({ message: err.message });  }};export const updateUser = async (req: Request, res: Response) => {  try {    const { id } = req.params;    const dto = plainToInstance(UpdateUserDto, req.body);    const errors = await validate(dto);    if (errors.length > 0) {      return res.status(400).json({ errors: errors.map(e => e.constraints) });    }    const user = await userService.update(Number(id), dto);    if (!user) return res.status(404).json({ message: 'User not found' });    res.json(user);  } catch (err) {    res.status(500).json({ message: 'Failed to update user' });  }};export const deleteUser = async (req: Request, res: Response) => {  try {    const { id } = req.params;    const deleted = await userService.remove(Number(id));    if (!deleted) return res.status(404).json({ message: 'User not found' });    res.status(204).send();  } catch (err) {    res.status(500).json({ message: 'Failed to delete user' });  }};
  7. Implement the service layer (src/services/userService.ts)
    import { userRepository } from '../repositories/userRepository';import { CreateUserDto, UpdateUserDto } from '../dtos';import * as bcrypt from 'bcryptjs';export const userService = {  async findAll() {    return await userRepository.findAll();  },  async create(dto: CreateUserDto) {    const hashedPwd = await bcrypt.hash(dto.password, 10);    return await userRepository.create({      ...dto,      password: hashedPwd    });  },  async update(id: number, dto: UpdateUserDto) {    const data: any = { ...dto };    if (dto.password) {      data.password = await bcrypt.hash(dto.password, 10);    }    return await userRepository.update(id, data);  },  async remove(id: number) {    return await userRepository.remove(id);  }};
  8. Add a simple repository (src/repositories/userRepository.ts) – using TypeORM
    import { EntityRepository, Repository } from 'typeorm';import { User } from '../entities/User';@EntityRepository(User)export class UserRepository extends Repository {  async findAll(): Promise {    return this.find();  }  async create(data: Partial): Promise {    const user = this.create(data);    return this.save(user);  }  async update(id: number, data: Partial): Promise {    await this.update(id, data);    const user = await this.findOne({ where: { id } });    return user ?? null;  }  async remove(id: number): Promise {    const result = await this.delete(id);    return result.affected !== 0;  }}
  9. Create a DTO (src/dtos/create-user.dto.ts)
    import { IsNotEmpty, IsEmail, MinLength } from 'class-validator';export class CreateUserDto {  @IsNotEmpty()  name: string;    @IsNotEmpty()  @IsEmail()  email: string;    @IsNotEmpty()  @MinLength(8)  password: string;}
  10. Add authentication middleware (simplified JWT) (src/middleware/auth.middleware.ts)
    import { Request, Response, NextFunction } from 'express';import jwt from 'jsonwebtoken';export const authenticate = (req: Request, res: Response, next: NextFunction) => {  const authHeader = req.headers.authorization;  if (!authHeader?.startsWith('Bearer ')) {    return res.status(401).json({ message: 'Missing or malformed token' });  }  const token = authHeader.split(' ')[1];  try {    const payload = jwt.verify(token, process.env.JWT_SECRET ?? 'fallback-secret');    (req as any).user = payload;    next();  } catch (err) {    return res.status(401).json({ message: 'Invalid or expired token' });  }};
  11. Set up error handling middleware (src/middleware/error.middleware.ts)
    import { Request, Response, NextFunction } from 'express';export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {  console.error(`${new Date().toISOString()} - ${err.message}`, err);  const status = err.status || 500;  res.status(status).json({    error: {      message: err.message || 'Internal Server Error',      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack })    }  });};
  12. Add a validation pipe (using class-validator and class-transformer)
    npm i class-validator class-transformer

    Create a middleware that validates DTO instances.

  13. Write unit tests with Jest and Supertest.
    npm i jest ts-jest @types/jest supertest @types/supertest
  14. Add scripts to package.json

    Add the following scripts section to your package.json:

    {  "scripts": {    "dev": "ts-node-dev src/app.ts",    "build": "tsc",    "start": "node dist/app.js",    "test": "jest"  }}
  15. Run and test

    Run npm run dev then use curl or Postman to hit http://localhost:3000/api/users.

Real-World Examples

Consider a SaaS offering that manages projects and tasks. The API exposes endpoints such as GET /api/projects, POST /api/projects, GET /api/projects/:id/tasks, and POST /api/projects/:id/tasks. Each project has an owner, and tasks belong to a project. By applying the layered architecture described earlier, you can isolate concerns: controllers handle HTTP verbs and authorization, services enforce business rules (e.g., only the project owner can delete a project), repositories interact with PostgreSQL via TypeORM, and DTOs ensure that incoming data conforms to expected shapes. Rate limiting middleware protects the endpoint from abuse, while JWT authentication secures user sessions. Logging middleware captures request IDs for traceability, and a centralized error handler returns consistent JSON error responses. This structure scales horizontally because each layer is stateless and can be replicated behind a load balancer.

Production Code Examples

Below are realistic snippets you can copy into a production repository.

src/app.ts

import express, { Request, Response, NextFunction } from 'express';import helmet from 'helmet';import rateLimit from 'express-rate-limit';import userRouter from './routes/userRoutes';import { errorHandler } from './middleware/error.middleware';const app = express();// Security middlewareapp.use(helmet());app.use(express.json({ limit: '1mb' }));// Rate limiting: 100 requests per 15 minutes per IPconst limiter = rateLimit({  windowMs: 15 * 60 * 1000,  max: 100,  standardHeaders: true,  legacyHeaders: false});app.use(limiter);// Routesapp.use('/api/users', userRouter);// 404 handlerapp.use((req: Request, res: Response) => {  res.status(404).json({ message: 'Route not found' }));// Central error handlerapp.use(errorHandler);export default app;

src/controllers/userController.ts

import { Request, Response } from 'express';import { userService } from '../services/userService';import { CreateUserDto, UpdateUserDto } from '../dtos';import { validate } from 'class-validator';import { plainToInstance } from 'class-transformer';export const getAllUsers = async (req: Request, res: Response) => {  try {    const users = await userService.findAll();    res.json(users);  } catch (err) {    res.status(500).json({ message: 'Failed to fetch users' });  }};export const createUser = async (req: Request, res: Response) => {  try {    const dto = plainToInstance(CreateUserDto, req.body);    const errors = await validate(dto);    if (errors.length > 0) {      return res.status(400).json({ errors: errors.map(e => e.constraints) });    }    const user = await userService.create(dto);    res.status(201).json(user);  } catch (err) {    res.status(400).json({ message: err.message });  }};export const updateUser = async (req: Request, res: Response) => {  try {    const { id } = req.params;    const dto = plainToInstance(UpdateUserDto, req.body);    const errors = await validate(dto);    if (errors.length > 0) {      return res.status(400).json({ errors: errors.map(e => e.constraints) });    }    const user = await userService.update(Number(id), dto);    if (!user) return res.status(404).json({ message: 'User not found' });    res.json(user);  } catch (err) {    res.status(500).json({ message: 'Failed to update user' });  }};export const deleteUser = async (req: Request, res: Response) => {  try {    const { id } = req.params;    const deleted = await userService.remove(Number(id));    if (!deleted) return res.status(404).json({ message: 'User not found' });    res.status(204).send();  } catch (err) {    res.status(500).json({ message: 'Failed to delete user' });  }};

src/services/userService.ts

import { userRepository } from '../repositories/userRepository';import { CreateUserDto, UpdateUserDto } from '../dtos';import * as bcrypt from 'bcryptjs';export const userService = {  async findAll() {    return await userRepository.findAll();  },  async create(dto: CreateUserDto) {    const hashedPwd = await bcrypt.hash(dto.password, 10);    return await userRepository.create({      ...dto,      password: hashedPwd    });  },  async update(id: number, dto: UpdateUserDto) {    const data: any = { ...dto };    if (dto.password) {      data.password = await bcrypt.hash(dto.password, 10);    }    return await userRepository.update(id, data);  },  async remove(id: number) {    return await userRepository.remove(id);  }};

src/repositories/userRepository.ts (using TypeORM)

import { EntityRepository, Repository } from 'typeorm';import { User } from '../entities/User';@EntityRepository(User)export class UserRepository extends Repository {  async findAll(): Promise {    return this.find();  }  async create(data: Partial): Promise {    const user = this.create(data);    return this.save(user);  }  async update(id: number, data: Partial): Promise {    await this.update(id, data);    const user = await this.findOne({ where: { id } });    return user ?? null;  }  async remove(id: number): Promise {    const result = await this.delete(id);    return result.affected !== 0;  }}

src/dtos/create-user.dto.ts

import { IsNotEmpty, IsEmail, MinLength } from 'class-validator';export class CreateUserDto {  @IsNotEmpty()  name: string;    @IsNotEmpty()  @IsEmail()  email: string;    @IsNotEmpty()  @MinLength(8)  password: string;}

src/middleware/auth.middleware.ts (JWT)

import { Request, Response, NextFunction } from 'express';import jwt from 'jsonwebtoken';export const authenticate = (req: Request, res: Response, next: NextFunction) => {  const authHeader = req.headers.authorization;  if (!authHeader?.startsWith('Bearer ')) {    return res.status(401).json({ message: 'Missing or malformed token' });  }  const token = authHeader.split(' ')[1];  try {    const payload = jwt.verify(token, process.env.JWT_SECRET ?? 'fallback-secret');    (req as any).user = payload;    next();  } catch (err) {    return res.status(401).json({ message: 'Invalid or expired token' });  }};

src/middleware/error.middleware.ts

import { Request, Response, NextFunction } from 'express';export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {  console.error(`${new Date().toISOString()} - ${err.message}`, err);  const status = err.status || 500;  res.status(status).json({    error: {      message: err.message || 'Internal Server Error',      ...(process.env.NODE_ENV !== 'production' && { stack: err.stack })    }  });};

Comparison Table

FeatureExpress.jsNestJSFastify
MaturityVery mature (since 2010)Mature (since 2017)Growing fast (since 2016)
Learning CurveLow – minimal abstractionMedium – opinionated architectureLow‑Medium – similar to Express but with schema‑based validation
PerformanceGood for most appsSlightly slower due to extra layersExcellent – optimized for speed
Built‑in DINoYes (Powered by TypeScript)No (plugins available)
DecoratorsNoYes (controllers, providers)No
MiddlewareExpress‑style middlewareUses middleware but also guards/interceptorsHook‑based lifecycle
TypeScript SupportVia @typesFirst‑classGood via plugins
EcosystemMassive middleware ecosystemOpinionated, many built‑in modules (auth, validation, etc.)Growing plugin ecosystem
Best ForSmall to medium apps, micro‑services, quick prototypesLarge enterprise apps, micro‑services with strict architectureHigh‑throughput APIs, micro‑services where latency matters

Best Practices

  • Use TypeScript strict mode – enables noImplicitAny, strictNullChecks, etc., to catch bugs early.
  • Separate concerns – keep controllers thin; move business logic to services and data access to repositories.
  • Validate input – employ a validation library (class-validator, Joi, Zod) at the controller entrance.
  • Handle errors centrally – wrap async route handlers with a utility or use Express error‑handling middleware.
  • Secure headers – use Helmet.js to set HTTP headers (CSP, HSTS, etc.).
  • Rate limit – protect endpoints from brute force and DoS attacks with express-rate-limit or similar.
  • Log consistently – adopt a structured logger (pino, winston) and include request IDs.
  • Use environment variables – never hard‑code secrets; load them via dotenv or a secrets manager.
  • Write tests – unit tests for services/repositories, integration tests for routes using Supertest.
  • Document API – generate OpenAPI specs with tools like tsc-routes or swagger-ui-express.
  • Keep dependencies up‑to‑date – run npm audit and npm outdated regularly.
  • Graceful shutdown – listen for SIGTERM/SIGINT and close servers and DB connections properly.
  • Use async/await – avoid callback hell and ensure proper error propagation.
  • Prefer DTOs over entities – prevents over‑exposing internal model fields.
  • Enable compression – use compression middleware for gzip/deflate responses.

Common Mistakes

  • Skipping input validation – leads to injection vulnerabilities and bad data.
  • Putting business logic in controllers – makes testing difficult and couples HTTP concerns to core logic.
  • Ignoring error handling – results in leaking stack traces or hanging requests.
  • Using any type excessively – defeats TypeScript's safety guarantees.
  • Hard‑coding secrets – exposes credentials in source control.
  • Not setting security headers – leaves the app open to XSS, clickjacking, etc.
  • Missing CORS configuration – either overly permissive or too restrictive, causing frontend issues.
  • Over‑using middleware – can degrade performance if heavy logic runs on every request.
  • Neglecting versioning – makes breaking changes painful for consumers.
  • Failing to close database connections – causes connection leaks and eventual exhaustion.
  • Using synchronous blocking code – blocks the event loop and degrades throughput.
  • Ignoring rate limiting – allows abusive traffic to overwhelm the service.
  • Not logging request IDs – hinders traceability in distributed systems.
  • Storing passwords in plain text – critical security flaw.
  • Overlooking content‑type validation – accepting arbitrary payloads can lead to DoS via large bodies.

Performance Tips

  • Enable gzip/deflate compression – reduces payload size dramatically.
  • Use connection pooling – for databases, reuse connections instead of creating new ones per request.
  • Cache frequent reads – employ Redis or in‑memory caches for data that changes infrequently.
  • Optimize database queries – add indexes, avoid SELECT *, use pagination.
  • Leverage clustering – run multiple Node.js processes via the cluster module or PM2 to utilize all CPU cores.
  • Avoid synchronous file system ops – use async versions or offload to worker threads.
  • Stream large responses – for file downloads, use streams to keep memory usage low.
  • Monitor event loop lag – use tools like clinic.js or node --inspect to detect blocking calls.
  • Keep payloads small – paginate lists, use field selection (GraphQL‑style) if needed.
  • Use HTTP/2 – if behind a capable reverse proxy (NGINX, Envoy) to multiplex requests.
  • Set appropriate cache‑control headers – lets clients and CDNs reuse responses.
  • Profile CPU usage – identify hot functions with clinic flame or 0x.
  • Minimize middleware – only apply needed middleware to specific routes.
  • Use lightweight serialization – avoid heavy libraries; native JSON.stringify is fast.
  • Load balance – place multiple instances behind a load balancer for horizontal scaling.

Security Considerations

  • Authentication – implement JWT or OAuth 2.0 with short‑lived access tokens and refresh tokens stored securely (HttpOnly cookies).
  • Authorization – enforce role‑based access control (RBAC) in services; never rely solely on frontend checks.
  • Input validation & sanitization – validate schema, sanitize inputs to prevent SQL injection and NoSQL injection.
  • Use parameterized queries – with TypeORM or query builders; never concatenate raw user input.
  • Protect against brute force – combine rate limiting with account lockout after failed attempts.
  • Secure cookies – set SameSite=Strict, Secure, and HttpOnly flags.
  • Helmet.js – apply to set secure HTTP headers (Content‑Security‑Policy, X‑Frame‑Options, etc.).
  • CORS policy – restrict origins to trusted domains; avoid * in production.
  • Dependency scanning – run npm audit and use tools like Snyk to detect vulnerable packages.
  • Environment segregation – keep dev, test, and prod configurations separate; never use real credentials in dev.
  • Logging sensitive data – ensure passwords, tokens, or PII are never written to logs.
  • HTTP method validation – disable unnecessary methods (e.g., TRACE) via middleware.
  • File upload security – validate MIME type, limit size, store outside web root, scan for malware.
  • Use HTTPOnly cookies for tokens – mitigates XSS theft.
  • Regularly rotate secrets – update JWT signing keys and database passwords periodically.

Deployment Notes

  • Containerize with Docker – create a Dockerfile that installs dependencies, builds TypeScript, and runs the compiled JavaScript.
    FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/package*.json ./RUN npm ci --only=productionUSER nodeEXPOSE 3000CMD node dist/app.js
  • Use a process manager – PM2 or Docker's restart policies ensure the container recovers from crashes.
  • Reverse proxy – place NGINX or Traefik in front to handle TLS termination, load balancing, and static assets.
  • Environment variables – inject secrets via Docker secrets, Kubernetes config maps, or a cloud provider's secret manager.
  • Health checks – implement a /health endpoint that returns 200 when the service and DB are reachable.
  • Logging to stdout – containers expect logs on standard output; configure your logger accordingly.
  • Observability – integrate with Prometheus metrics (e.g., prom-client) and distributed tracing (OpenTelemetry).
  • Database migrations – run migrations as part of the startup script or via a separate CI step; never rely on manual schema changes.
  • Scaling – horizontally scale behind a load balancer; ensure statelessness (store sessions in Redis or JWT).
  • Backup strategy – schedule regular backups of your database and test restore procedures.
  • CI/CD pipeline – automate linting, testing, building, and deploying to a staging environment before production.
  • Monitoring – set alerts on latency, error rates, and resource utilization (CPU, memory).

Debugging Tips

  • Enable source maps – when running in development, use ts-node-dev with --respawn and --transpile-only for fast reloads; in production, keep source maps disabled but retain them for post‑mortem analysis.
  • Use the built‑in debugger – node --inspect-brk lets you attach Chrome DevTools and set breakpoints.
  • Log request IDs – generate a UUID per request and include it in all log lines; this makes tracing a request across services trivial.
  • Check middleware order – ensure that error‑handling middleware is placed after all routes; otherwise errors may bypass it.
  • Inspect middleware stack – app._router.stack (though not public) can be logged in dev to see what's registered.
  • Validate environment variables – at startup, log which vars are present (without revealing secrets) to catch missing configs early.
  • Use npm ls – detect duplicate or incompatible package versions that can cause weird runtime errors.
  • Run linters – ESLint with TypeScript rules catches many bugs before they manifest.
  • Profile memory – node --inspect with Chrome's Memory panel helps spot leaks.
  • Simulate production loads – use tools like Artillery or k6 to reproduce concurrency issues.
  • Check async error propagation – always await promises or attach .catch; unhandled rejections crash the process.
  • Review Docker logs – docker logs <container> shows stdout/stderr; ensure your app logs there.
  • Check port conflicts – if the container fails to start, verify that the host port isn't already in use.
  • Read error messages carefully – Node.js stacks often point to the exact line; don't ignore them.
  • Keep a reproducible test case – isolate the failing scenario in a unit test to verify fixes.

FAQ

Do I need to use an ORM like TypeORM or can I query the database directly?

You can absolutely use a raw query builder or a lightweight library like pg for PostgreSQL. ORMs add convenience features such as entity management, migrations, and relationship handling, but they also introduce abstraction overhead. For simple CRUD APIs, a query builder may be sufficient; for complex domain models with relationships, an ORM saves boilerplate.

How do I version my REST API?

A common approach is to include the version in the URL path, e.g., /api/v1/users. This makes breaking changes explicit and allows multiple versions to coexist. Alternatively, you can use custom headers (like Accept: application/vnd.myapi.v2+json) but URL versioning is simpler and more visible to consumers.

What's the difference between middleware and interceptors in NestJS?

In Express, middleware functions have access to the request, response, and next callback and can terminate the request early or pass control forward. NestJS builds on this concept with interceptors, which can transform the result returned from a controller handler, as well as run logic before and after the handler. Interceptors are therefore more suited for cross‑cutting concerns like logging response times or mapping exceptions, whereas middleware is ideal for tasks such as authentication, body parsing, and CORS.

Should I store JWT tokens in localStorage or cookies?

Storing JWT tokens in HttpOnly, Secure cookies is generally safer because it mitigates XSS attacks—JavaScript cannot read the cookie. localStorage is vulnerable to XSS stealing the token. If you need to send the token to a non‑first‑party domain, you might use the Authorization header with a short‑lived access token and keep the refresh token in an HttpOnly cookie.

How can I prevent SQL injection when using TypeORM?

Always use the repository or query builder methods that accept parameters separately, e.g., repository.find({ where: { email: email } }) or createQueryBuilder().where("email = :email", { email: email }). Never concatenate user input directly into the query string.

Is it necessary to use a validation library like class-validator?

While you could manually check each field, a validation library provides declarative, reusable rules and automatic transformation of plain objects to class instances. It reduces boilerplate and ensures consistent validation across layers.

What's the best way to handle file uploads in a Node.js API?

Use a middleware like multer to parse multipart/form-data, limit file size, and restrict allowed MIME types. Store uploaded files outside the public directory or in a cloud storage bucket (e.g., AWS S3). After saving, return only a reference (URL or filename) in the JSON response, never the raw file path.

How do I make my API compatible with HTTP/2?

Node.js core HTTP/2 support is available via the http2 module. However, most production deployments place a reverse proxy (NGINX, Envoy, or AWS ALB) in front of the Node.js process; the proxy handles HTTP/2 with clients and forwards HTTP/1.1 to the app. This setup lets you benefit from HTTP/2 multiplexing without changing your application code.

Can I use the same DTO for both input and output?

It's possible, but not recommended. Input DTOs often need looser validation (e.g., optional fields for updates) while output DTOs may exclude sensitive properties like passwords or internal IDs. Keeping them separate avoids accidental over‑exposure and makes the intent clearer.

What's the simplest way to add rate limiting to specific routes only?

Create a separate limiter instance and apply it as middleware only to the routes you want to protect, for example:

const authLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 5 });app.post('/api/login', authLimiter, loginController);
This leaves other routes unaffected.

Conclusion

Building a REST API with Node.js, Express, and TypeScript gives you a solid foundation for scalable, maintainable services. By following the layered architecture, applying rigorous validation, securing endpoints, and observing performance best practices, you can create APIs that stand up to production demands. Start small, iterate with tests, and continuously refine your design as requirements evolve. Now that you have the blueprint, take the next step: scaffold a new repository, implement the patterns described here, and ship your first feature. Happy coding!