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
- Introduction
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
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
- 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 - Create the source folder
mkdir -p src/{controllers,services,repositories,dtos,middleware,utils} - 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; - 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; - Wire routes in app.ts
In
app.tsafter setting up middleware, add:import userRouter from './routes/userRoutes';app.use('/api/users', userRouter); - 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' }); }}; - 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); }}; - Add a simple repository (
src/repositories/userRepository.ts) – using TypeORMimport { 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; }} - 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;} - 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' }); }}; - 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 }) } });}; - Add a validation pipe (using class-validator and class-transformer)
npm i class-validator class-transformerCreate a middleware that validates DTO instances.
- Write unit tests with Jest and Supertest.
npm i jest ts-jest @types/jest supertest @types/supertest - 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" }} - Run and test
Run
npm run devthen use curl or Postman to hithttp://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
| Feature | Express.js | NestJS | Fastify |
|---|---|---|---|
| Maturity | Very mature (since 2010) | Mature (since 2017) | Growing fast (since 2016) |
| Learning Curve | Low – minimal abstraction | Medium – opinionated architecture | Low‑Medium – similar to Express but with schema‑based validation |
| Performance | Good for most apps | Slightly slower due to extra layers | Excellent – optimized for speed |
| Built‑in DI | No | Yes (Powered by TypeScript) | No (plugins available) |
| Decorators | No | Yes (controllers, providers) | No |
| Middleware | Express‑style middleware | Uses middleware but also guards/interceptors | Hook‑based lifecycle |
| TypeScript Support | Via @types | First‑class | Good via plugins |
| Ecosystem | Massive middleware ecosystem | Opinionated, many built‑in modules (auth, validation, etc.) | Growing plugin ecosystem |
| Best For | Small to medium apps, micro‑services, quick prototypes | Large enterprise apps, micro‑services with strict architecture | High‑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
dotenvor 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-routesorswagger-ui-express. - Keep dependencies up‑to‑date – run
npm auditandnpm outdatedregularly. - 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
compressionmiddleware 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
anytype 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
clustermodule 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.jsornode --inspectto 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 flameor0x. - Minimize middleware – only apply needed middleware to specific routes.
- Use lightweight serialization – avoid heavy libraries; native
JSON.stringifyis 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, andHttpOnlyflags. - 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 auditand 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
Dockerfilethat 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
/healthendpoint 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-devwith--respawnand--transpile-onlyfor fast reloads; in production, keep source maps disabled but retain them for post‑mortem analysis. - Use the built‑in debugger –
node --inspect-brklets 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 --inspectwith 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
awaitpromises 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!