Skip to main content
Back to Blog
Node.jsREST APIExpressFastifyBackendJavaScriptTypeScriptAPI DesignPerformanceBest Practices

Node.js 22+ Best Practices for Building REST APIs in 2026

A comprehensive guide to building production-ready REST APIs with Node.js 22+ in 2026. Covers folder structure, error handling, validation, logging, and performance tips for Express and Fastify.

May 14, 202616 min readNiraj Kumar

Building a REST API is one of the most common backend tasks a developer faces. But there is a massive difference between an API that works and one that is production-ready — secure, observable, maintainable, and performant under real-world load.

Node.js 22 brought significant upgrades: a stable --experimental-vm-modules flag that is no longer experimental, native fetch and WebStreams baked in, improved node:sqlite module support, and a faster V8 engine. Paired with TypeScript 5.x and modern tooling like Fastify v5 or Express 5, the ecosystem has never been in better shape.

In this guide, we will walk through everything you need to build REST APIs that are ready for 2026 production environments — from folder structure to error handling, input validation, structured logging, and performance tuning. Whether you are starting a greenfield project or improving an existing one, there is something here for you.


1. Setting Up Your Project

Start clean. In 2026, there is no excuse for using require() in new projects unless you have a very specific reason. Node.js 22 ships with full ESM support and a built-in test runner.

# Create project
mkdir my-api && cd my-api
npm init -y

# Install TypeScript and Node.js types
npm install -D typescript ts-node @types/node

# Initialize TypeScript config
npx tsc --init

Your tsconfig.json should target modern output:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Use tsx for development hot-reloading (it is faster than ts-node and supports ESM natively):

npm install -D tsx

Add your package.json scripts:

{
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsc",
    "start": "node dist/main.js",
    "test": "node --test"
  }
}

Why not nodemon? tsx watch uses esbuild under the hood, giving you sub-100ms restarts. nodemon was great in 2018. In 2026, tsx watch is the standard.


One of the most debated topics in any Node.js project is how to organize your files. The answer depends on project size, but for most production APIs a feature-based (vertical slice) structure scales better than a layer-based one.

❌ Layer-Based Structure (Do Not Do This at Scale)

src/
├── controllers/
│   ├── userController.ts
│   └── orderController.ts
├── services/
│   ├── userService.ts
│   └── orderService.ts
├── models/
│   ├── userModel.ts
│   └── orderModel.ts
└── routes/
    ├── userRoutes.ts
    └── orderRoutes.ts

This structure forces you to jump between four folders just to work on one feature. It does not scale.

src/
├── modules/
│   ├── users/
│   │   ├── user.controller.ts
│   │   ├── user.service.ts
│   │   ├── user.repository.ts
│   │   ├── user.schema.ts        # Zod schemas
│   │   ├── user.routes.ts
│   │   └── user.types.ts
│   └── orders/
│       ├── order.controller.ts
│       ├── order.service.ts
│       ├── order.repository.ts
│       ├── order.schema.ts
│       ├── order.routes.ts
│       └── order.types.ts
├── common/
│   ├── errors/
│   │   ├── AppError.ts
│   │   └── errorHandler.ts
│   ├── middlewares/
│   │   ├── auth.middleware.ts
│   │   ├── rateLimiter.middleware.ts
│   │   └── requestId.middleware.ts
│   ├── utils/
│   │   ├── logger.ts
│   │   └── asyncHandler.ts
│   └── types/
│       └── express.d.ts
├── config/
│   ├── env.ts                    # Validated env vars
│   └── database.ts
├── app.ts                        # App setup (no listen)
└── main.ts                       # Entry point (listen)

This structure means:

  • Every feature is self-contained. You can delete a folder and the feature is gone.
  • Onboarding is fast. A new developer can find everything about users in src/modules/users/.
  • Testing is easy. Mock the service, test the controller; mock the repository, test the service.

3. Choosing Your Framework: Express 5 vs Fastify 5

Both are excellent choices in 2026. Here is a quick comparison:

FeatureExpress 5Fastify 5
Performance~70k req/s~130k req/s
TypeScript SupportGood (with types)Excellent (first-class)
Schema ValidationManual (use Zod/Joi)Built-in (JSON Schema / Zod)
Plugin SystemMiddleware-basedEncapsulation + hooks
Learning CurveVery lowModerate
Ecosystem MaturityVery matureMature and growing
OpenAPI / SwaggerManual setupFirst-class via @fastify/swagger

For high-throughput APIs or microservices, Fastify is the better pick. For teams with Express experience or quick MVPs, Express 5 is still perfectly fine.

Express 5 — Minimal Setup

Express 5 finally ships with built-in async error handling, so you no longer need to wrap every route in a try/catch or use a wrapper utility.

// src/app.ts
import express, { Application } from "express";
import { userRoutes } from "./modules/users/user.routes.js";
import { globalErrorHandler } from "./common/errors/errorHandler.js";
import { requestIdMiddleware } from "./common/middlewares/requestId.middleware.js";

export function createApp(): Application {
  const app = express();

  app.use(express.json({ limit: "10kb" }));
  app.use(express.urlencoded({ extended: true }));
  app.use(requestIdMiddleware);

  // Routes
  app.use("/api/v1/users", userRoutes);

  // 404 handler
  app.use((_req, res) => {
    res.status(404).json({ success: false, message: "Route not found" });
  });

  // Global error handler (must be last)
  app.use(globalErrorHandler);

  return app;
}
// src/main.ts
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { logger } from "./common/utils/logger.js";

const app = createApp();

app.listen(env.PORT, () => {
  logger.info({ port: env.PORT, env: env.NODE_ENV }, "🚀 Server started");
});

Fastify 5 — Minimal Setup

// src/app.ts
import Fastify from "fastify";
import { userPlugin } from "./modules/users/user.routes.js";

export async function buildApp() {
  const app = Fastify({
    logger: {
      level: "info",
      transport: {
        target: "pino-pretty",
        options: { colorize: true },
      },
    },
  });

  await app.register(userPlugin, { prefix: "/api/v1/users" });

  return app;
}

4. Input Validation with Zod

Never trust incoming data. Validate every request body, query parameter, and route parameter before it reaches your business logic. In 2026, Zod is the industry standard for TypeScript-first validation.

npm install zod

Define Your Schema

// src/modules/users/user.schema.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name too long")
    .trim(),
  email: z.string().email("Invalid email address").toLowerCase(),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Must contain an uppercase letter")
    .regex(/[0-9]/, "Must contain a number"),
  role: z.enum(["admin", "user", "moderator"]).default("user"),
});

export const updateUserSchema = createUserSchema.partial().omit({ password: true });

export const userIdParamSchema = z.object({
  id: z.string().uuid("Invalid user ID format"),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

Build a Reusable Validation Middleware

// src/common/middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
import { AppError } from "../errors/AppError.js";

type ValidateTarget = "body" | "query" | "params";

export function validate(schema: ZodSchema, target: ValidateTarget = "body") {
  return (req: Request, _res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      const errors = result.error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));
      throw new AppError("Validation failed", 422, errors);
    }

    // Replace with the sanitized/coerced data
    req[target] = result.data;
    next();
  };
}

Use It in Your Routes

// src/modules/users/user.routes.ts
import { Router } from "express";
import { validate } from "../../common/middlewares/validate.middleware.js";
import { createUserSchema, userIdParamSchema } from "./user.schema.js";
import { UserController } from "./user.controller.js";

const router = Router();
const controller = new UserController();

router.get("/", controller.getAll);
router.get("/:id", validate(userIdParamSchema, "params"), controller.getById);
router.post("/", validate(createUserSchema), controller.create);
router.patch(
  "/:id",
  validate(userIdParamSchema, "params"),
  validate(updateUserSchema),
  controller.update
);
router.delete("/:id", validate(userIdParamSchema, "params"), controller.delete);

export { router as userRoutes };

5. Centralized Error Handling

Scattered try/catch blocks with res.status(500).json(...) in every controller is a code smell. Centralize all error handling in one place.

Custom Error Class

// src/common/errors/AppError.ts
export class AppError extends Error {
  public readonly statusCode: number;
  public readonly isOperational: boolean;
  public readonly details?: unknown;

  constructor(
    message: string,
    statusCode: number = 500,
    details?: unknown,
    isOperational: boolean = true
  ) {
    super(message);
    this.name = "AppError";
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.details = details;

    // Restore prototype chain
    Object.setPrototypeOf(this, new.target.prototype);
    Error.captureStackTrace(this, this.constructor);
  }

  static notFound(resource: string) {
    return new AppError(`${resource} not found`, 404);
  }

  static unauthorized(message = "Unauthorized") {
    return new AppError(message, 401);
  }

  static forbidden(message = "Forbidden") {
    return new AppError(message, 403);
  }

  static badRequest(message: string, details?: unknown) {
    return new AppError(message, 400, details);
  }
}

Global Error Handler Middleware

// src/common/errors/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "./AppError.js";
import { logger } from "../utils/logger.js";
import { ZodError } from "zod";

export function globalErrorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  // Handle Zod validation errors
  if (err instanceof ZodError) {
    return res.status(422).json({
      success: false,
      message: "Validation failed",
      errors: err.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      })),
    });
  }

  // Handle known operational errors
  if (err instanceof AppError && err.isOperational) {
    logger.warn({ err, requestId: req.headers["x-request-id"] }, err.message);
    return res.status(err.statusCode).json({
      success: false,
      message: err.message,
      ...(err.details ? { errors: err.details } : {}),
    });
  }

  // Unknown / programmer errors — log with full stack
  logger.error({ err, requestId: req.headers["x-request-id"] }, "Unexpected error");

  // In production, hide internal details
  const isProd = process.env.NODE_ENV === "production";
  return res.status(500).json({
    success: false,
    message: isProd ? "An unexpected error occurred" : err.message,
    ...(isProd ? {} : { stack: err.stack }),
  });
}

Async Handler Utility (Express 4/5)

In Express 5, async errors are caught automatically. But if you still maintain Express 4, use this:

// src/common/utils/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from "express";

type AsyncFn = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;

export const asyncHandler = (fn: AsyncFn): RequestHandler =>
  (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };

6. Structured Logging with Pino

console.log is not a logging strategy. In production, you need structured, queryable logs. Pino is the fastest Node.js logger and produces JSON logs by default — exactly what log aggregators like Datadog, Loki, or CloudWatch need.

npm install pino pino-pretty
npm install -D @types/pino
// src/common/utils/logger.ts
import pino from "pino";
import { env } from "../../config/env.js";

export const logger = pino({
  level: env.LOG_LEVEL ?? "info",
  base: {
    service: "my-api",
    env: env.NODE_ENV,
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  transport:
    env.NODE_ENV !== "production"
      ? { target: "pino-pretty", options: { colorize: true } }
      : undefined,
});

Request ID Middleware

Every request should get a unique ID so you can trace a full request lifecycle through your logs:

// src/common/middlewares/requestId.middleware.ts
import { Request, Response, NextFunction } from "express";
import { randomUUID } from "node:crypto";

export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
  const requestId = (req.headers["x-request-id"] as string) ?? randomUUID();
  req.headers["x-request-id"] = requestId;
  res.setHeader("x-request-id", requestId);
  next();
}

HTTP Request Logger

// src/common/middlewares/httpLogger.middleware.ts
import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger.js";

export function httpLogger(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();

  res.on("finish", () => {
    const duration = Date.now() - start;
    const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";

    logger[level]({
      requestId: req.headers["x-request-id"],
      method: req.method,
      url: req.originalUrl,
      statusCode: res.statusCode,
      duration: `${duration}ms`,
      userAgent: req.headers["user-agent"],
      ip: req.ip,
    });
  });

  next();
}

7. Authentication and Authorization

Environment Variable Validation

Before wiring up auth, validate your environment variables at startup so you crash fast and loud if anything is missing:

// src/config/env.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  JWT_EXPIRES_IN: z.string().default("15m"),
  LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ Invalid environment variables:");
  console.error(parsed.error.format());
  process.exit(1);
}

export const env = parsed.data;

JWT Authentication Middleware

// src/common/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { env } from "../../config/env.js";
import { AppError } from "../errors/AppError.js";

export interface JwtPayload {
  sub: string;
  role: "admin" | "user" | "moderator";
  iat: number;
  exp: number;
}

export function authenticate(req: Request, _res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    throw AppError.unauthorized("Missing or malformed authorization header");
  }

  const token = authHeader.slice(7);

  try {
    const payload = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
    req.user = payload; // Extend express Request in express.d.ts
    next();
  } catch {
    throw AppError.unauthorized("Invalid or expired token");
  }
}

export function authorize(...roles: JwtPayload["role"][]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    if (!req.user || !roles.includes(req.user.role)) {
      throw AppError.forbidden("You do not have permission to access this resource");
    }
    next();
  };
}
// src/common/types/express.d.ts
import { JwtPayload } from "../middlewares/auth.middleware.js";

declare global {
  namespace Express {
    interface Request {
      user?: JwtPayload;
    }
  }
}

8. Performance Tips

8.1 Enable Response Compression

npm install compression
npm install -D @types/compression
import compression from "compression";
app.use(compression());

8.2 Rate Limiting

npm install @fastify/rate-limit       # Fastify
npm install express-rate-limit         # Express
// Express
import rateLimit from "express-rate-limit";

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: { success: false, message: "Too many requests, slow down." },
});

app.use("/api/", limiter);

8.3 Use Node.js Cluster with the Worker Threads Model

For CPU-bound work, use node:worker_threads. For I/O-bound APIs, use node:cluster to utilize all CPU cores:

// src/main.ts
import cluster from "node:cluster";
import os from "node:os";
import { createApp } from "./app.js";
import { env } from "./config/env.js";
import { logger } from "./common/utils/logger.js";

if (cluster.isPrimary && env.NODE_ENV === "production") {
  const numCPUs = os.availableParallelism();
  logger.info(`Primary process ${process.pid} starting ${numCPUs} workers`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker) => {
    logger.warn(`Worker ${worker.process.pid} died. Restarting...`);
    cluster.fork();
  });
} else {
  const app = createApp();
  app.listen(env.PORT, () => {
    logger.info({ pid: process.pid, port: env.PORT }, "Worker started");
  });
}

Note: In containerized environments (Docker + Kubernetes), prefer running a single process per container and scale horizontally. The cluster module is more useful for bare-metal or VM deployments.

8.4 Connection Pooling for Databases

Never open a new database connection per request. Use a connection pool:

// src/config/database.ts (using postgres with pg pool)
import { Pool } from "pg";
import { env } from "./env.js";

export const db = new Pool({
  connectionString: env.DATABASE_URL,
  max: 20,           // Max connections in pool
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 2_000,
});

8.5 Cache Aggressively with Redis

For read-heavy endpoints, cache responses in Redis:

// src/common/utils/cache.ts
import { createClient } from "redis";
import { logger } from "./logger.js";

const client = createClient({ url: process.env.REDIS_URL });

client.on("error", (err) => logger.error({ err }, "Redis client error"));
await client.connect();

export async function getOrSet<T>(
  key: string,
  ttlSeconds: number,
  fetchFn: () => Promise<T>
): Promise<T> {
  const cached = await client.get(key);
  if (cached) return JSON.parse(cached) as T;

  const fresh = await fetchFn();
  await client.setEx(key, ttlSeconds, JSON.stringify(fresh));
  return fresh;
}

8.6 Stream Large Responses

If you are sending large datasets, do not buffer the entire response in memory:

import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";

router.get("/export/csv", authenticate, async (req, res) => {
  res.setHeader("Content-Type", "text/csv");
  res.setHeader("Content-Disposition", 'attachment; filename="export.csv"');
  await pipeline(createReadStream("./data/export.csv"), res);
});

9. Common Mistakes to Avoid

❌ Mistake 1 — Returning Raw Database Errors to Clients

// Bad
catch (err) {
  res.status(500).json({ error: err.message }); // Leaks DB internals
}

// Good
catch (err) {
  logger.error({ err }, "Database error");
  throw new AppError("An error occurred while processing your request", 500);
}

❌ Mistake 2 — Not Handling Unhandled Promise Rejections

// Add to main.ts
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "Unhandled promise rejection — shutting down");
  process.exit(1);
});

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "Uncaught exception — shutting down");
  process.exit(1);
});

❌ Mistake 3 — Using any Type in TypeScript

Every any you write is a hole in your type system. Use unknown and narrow it:

// Bad
function parseData(data: any) { ... }

// Good
function parseData(data: unknown) {
  const parsed = mySchema.parse(data); // Zod handles the narrowing
  return parsed;
}

❌ Mistake 4 — Ignoring Graceful Shutdown

When Kubernetes sends SIGTERM, your app needs to drain existing connections before dying:

// src/main.ts
const server = app.listen(env.PORT, () => {
  logger.info("Server started");
});

function gracefulShutdown(signal: string) {
  logger.info(`Received ${signal}. Starting graceful shutdown...`);
  server.close(async () => {
    await db.end(); // Close DB pool
    logger.info("Server closed. Exiting.");
    process.exit(0);
  });

  // Force shutdown after 10 seconds
  setTimeout(() => {
    logger.error("Forced shutdown after timeout");
    process.exit(1);
  }, 10_000);
}

process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));

❌ Mistake 5 — Storing Secrets in Code or .env Files in Production

Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler) and inject secrets at runtime. Never commit .env files with real secrets, even to private repositories.

❌ Mistake 6 — No API Versioning

Always version your API from day one:

app.use("/api/v1/users", userRoutes);
app.use("/api/v2/users", userRoutesV2);

When you break a contract, bump the version — never break existing consumers silently.


10. 🚀 Pro Tips

Use node:crypto.randomUUID() instead of the uuid package. Since Node.js 15, crypto.randomUUID() is available natively and is faster:

import { randomUUID } from "node:crypto";
const id = randomUUID(); // No npm package needed

Use Promise.allSettled instead of Promise.all when partial failures are acceptable:

const [userResult, prefsResult] = await Promise.allSettled([
  fetchUser(id),
  fetchPreferences(id),
]);

const user = userResult.status === "fulfilled" ? userResult.value : null;

Enable keepAlive on your HTTP server to avoid expensive TCP handshakes on every request:

server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;

Use node --watch for zero-dependency development restarts when you do not need TypeScript:

node --watch --experimental-specifier-resolution=node dist/main.js

Add a /health and /ready endpoint for Kubernetes probes:

app.get("/health", (_req, res) => res.json({ status: "ok", uptime: process.uptime() }));

app.get("/ready", async (_req, res) => {
  try {
    await db.query("SELECT 1"); // Check DB connection
    res.json({ status: "ready" });
  } catch {
    res.status(503).json({ status: "not ready" });
  }
});

Profile before you optimize. Use node --prof and node --prof-process or the built-in node:inspector to find actual bottlenecks instead of guessing.

Write your API documentation as code, not docs. Use @fastify/swagger or swagger-jsdoc + swagger-ui-express so your docs are always in sync with your routes.


11. 📌 Key Takeaways

  • Folder structure matters. A feature-based structure — co-locating routes, controllers, services, and schemas per feature — scales far better than layer-based separation as your team and codebase grow.

  • Validation at the boundary is non-negotiable. Use Zod (or Fastify's JSON Schema) to validate every incoming request at the route layer. Never let raw, unvalidated data reach your service or database layer.

  • Centralize your error handling. Build typed AppError classes and funnel all errors through a single global error handler. This gives you consistent error responses and makes logging trivially easy.

  • Structured JSON logging is production-grade logging. Use Pino, tag every log with a requestId, and ship logs to a centralized aggregator. console.log belongs in quick scripts, not in production APIs.

  • Performance is a feature. Use response compression, rate limiting, connection pooling, Redis caching, and graceful shutdown from the start — retrofitting these into a live system is painful.

  • Validate your environment. Use Zod to parse process.env at startup. Crashing immediately with a clear error message is infinitely better than a silent misconfiguration causing subtle bugs in production.


12. References


All Articles
Node.jsREST APIExpressFastifyBackendJavaScriptTypeScriptAPI DesignPerformanceBest Practices

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.