Bablu Kumar Singh
Back to Blog
Backend Development
7 min read
May 2, 2026

Node.js API Design Best Practices

Node.js API Design Best Practices

Building APIs is the bread and butter of backend development, but a poorly designed API turns into a maintenance nightmare the moment traffic scales. After shipping more than 200 endpoints across production SaaS platforms, I have distilled the patterns that consistently prevent regressions and keep codebases approachable for every developer on the team.

Project Structure That Scales

The single biggest lever for long-term maintainability is a consistent folder layout. I use a feature-based structure rather than the classic MVC split:

javascript Code Block
src/
├── modules/
│   ├── users/
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── users.routes.ts
│   │   ├── users.validation.ts
│   │   └── users.types.ts
│   └── orders/
│       ├── orders.controller.ts
│       └── ...
├── middleware/
│   ├── auth.ts
│   ├── errorHandler.ts
│   └── rateLimiter.ts
├── config/
│   └── db.ts
└── app.ts

Every feature lives in its own directory with its own controller, service, routes, and validation schemas. When a new developer joins, they can reason about the users module without understanding the entire codebase.

Request Validation with Zod

Never trust client input. I validate every request body, query parameter, and URL parameter at the route level with Zod:

typescript Code Block
import { z } from 'zod';

export const createUserSchema = z.object({
  body: z.object({
    email: z.string().email('Invalid email address'),
    name: z.string().min(2, 'Name must be at least 2 characters'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
  }),
});

export type CreateUserInput = z.infer<typeof createUserSchema>['body'];

A generic validation middleware applies any Zod schema to the incoming request:

typescript Code Block
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

export const validate =
  (schema: AnyZodObject) =>
  (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          status: 'error',
          errors: err.errors.map((e) => ({
            path: e.path.join('.'),
            message: e.message,
          })),
        });
      }
      next(err);
    }
  };

The validation middleware is then attached to routes declaratively:

typescript Code Block
router.post('/users', validate(createUserSchema), usersController.create);

Centralized Error Handling

Scattered try/catch blocks create inconsistent responses. Instead, I wrap every controller action with an async handler and funnel errors into a single error middleware:

typescript Code Block
// asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

export const asyncHandler =
  (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) =>
  (req: Request, res: Response, next: NextFunction) =>
    Promise.resolve(fn(req, res, next)).catch(next);
typescript Code Block
// errorHandler.ts
import { Request, Response, NextFunction } from 'express';

export class AppError extends Error {
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

export const errorHandler = (
  err: AppError | Error,
  _req: Request,
  res: Response,
  _next: NextFunction
) => {
  const statusCode = 'statusCode' in err ? err.statusCode : 500;
  res.status(statusCode).json({
    status: 'error',
    message: err.message || 'Internal Server Error',
  });
};

API Versioning

Prefix every route group with a version tag (/api/v1/...). When a breaking change is required, spin up /api/v2/ and deprecate the old version on a timeline:

typescript Code Block
import v1Routes from './routes/v1';
import v2Routes from './routes/v2';

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

This approach lets mobile clients that cannot force-update continue operating against v1 while web clients adopt v2 immediately.

Rate Limiting and Security Headers

Apply rate limiting early in the middleware chain. I use the express-rate-limit package combined with a Redis store for multi-instance deployments:

typescript Code Block
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redisClient } from './config/redis';

const limiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // limit each IP to 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
});

app.use(limiter);

Add helmet for security headers and cors to whitelist trusted origins — two lines that harden an API surface dramatically.

Consistent Response Envelopes

Wrap every response in a predictable envelope so frontend teams never guess the shape:

typescript Code Block
res.status(200).json({
  status: 'success',
  data: { user },
  meta: { page: 1, limit: 20, total: 58 },
});

Key Takeaways

  • Validate early — reject bad requests before they touch your database.
  • Handle errors centrally — one handler, one response shape.
  • Version your APIs — protect existing consumers while evolving.
  • Rate-limit everything — defend against abuse and runaway clients.
  • Structure by feature — keep cognitive load proportional to the task.

These patterns have survived traffic spikes, midnight on-call incidents, and multi-team handoffs. Adopt them early, and your future self will be grateful.

#Node.js#Express.js#API Design#REST APIs
Bablu Kumar Singh
Written by

Bablu Kumar Singh

Backend-Focused Full Stack Developer

Backend-Focused Full Stack Developer specializing in Node.js, MongoDB, PostgreSQL, Redis, RabbitMQ, AWS, Docker, System Design, and React Native.

You May Also Like

Designing a Role-Based Access Control (RBAC) System from Scratch
Backend Development
5 min read

Designing a Role-Based Access Control (RBAC) System from Scratch

A blueprint on implementing hierarchical permission matrices in Express.js middleware using bitwise operations and MongoDB caching.

May 24, 2026Read
Building Robust Role-Based Access Control (RBAC) Systems
Backend Development
6 min read

Building Robust Role-Based Access Control (RBAC) Systems

A practical guide to designing and implementing RBAC in Node.js applications — covering permission models, middleware design, hierarchical roles, and database schema patterns.

May 24, 2026Read
Authentication Systems in Node.js: JWT, Refresh Tokens, and Security
Backend Development
7 min read

Authentication Systems in Node.js: JWT, Refresh Tokens, and Security

Build a production-grade authentication system in Node.js — covering JWT access tokens, refresh token rotation, password hashing with bcrypt, and common security pitfalls to avoid.

May 28, 2026Read