Node.js REST API Best Practices: Structure, Validation, Error Handling and Security
Building a Node.js API that handles a few hundred requests is easy. Building one that is maintainable, secure, and observable at scale is a different challenge. This guide covers the patterns and practices that separate hobby projects from production APIs.
Project Structure
A clean structure makes the codebase navigable and testable:
codesrc/ βββ app.js -- Express app setup (no listen() call) βββ server.js -- server.listen() -- separation aids testing βββ config/ β βββ index.js -- environment variables, validated at startup βββ middleware/ β βββ auth.js -- JWT validation β βββ errorHandler.js -- centralized error handling β βββ rateLimiter.js β βββ requestLogger.js βββ modules/ β βββ users/ β βββ users.router.js β βββ users.controller.js -- HTTP layer only β βββ users.service.js -- business logic β βββ users.repository.js -- data access β βββ users.schema.js -- Zod validation schemas β βββ users.test.js βββ utils/ β βββ AppError.js -- custom error class β βββ asyncHandler.js -- wraps async route handlers β βββ logger.js -- structured logging βββ db/ βββ index.js -- connection pool βββ migrations/
Configuration Management
javascript-- config/index.js import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), REDIS_URL: z.string().optional(), LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), }); -- Validate at startup -- fail fast if misconfigured const result = envSchema.safeParse(process.env); if (!result.success) { console.error("Invalid environment configuration:"); console.error(result.error.flatten().fieldErrors); process.exit(1); } export const config = result.data;
Input Validation with Zod
Never trust user input. Validate at the boundary:
javascript-- modules/users/users.schema.js import { z } from "zod"; export const createUserSchema = z.object({ body: z.object({ username: z.string() .min(3, "Username must be at least 3 characters") .max(30) .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers and underscores"), email: z.string().email("Invalid email address"), 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(["user", "admin"]).default("user"), }), }); export const getUserSchema = z.object({ params: z.object({ id: z.coerce.number().int().positive(), }), }); export const listUsersSchema = z.object({ query: z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().max(100).optional(), role: z.enum(["user", "admin"]).optional(), }), }); -- middleware/validate.js export const validate = (schema) => async (req, res, next) => { try { const parsed = await schema.parseAsync({ body: req.body, params: req.params, query: req.query, }); req.body = parsed.body ?? req.body; req.params = parsed.params ?? req.params; req.query = parsed.query ?? req.query; next(); } catch (error) { if (error instanceof z.ZodError) { return res.status(422).json({ error: "Validation failed", details: error.flatten().fieldErrors, }); } next(error); } };
Centralized Error Handling
javascript-- utils/AppError.js export class AppError extends Error { constructor(message, statusCode, code = null) { super(message); this.statusCode = statusCode; this.code = code; this.isOperational = true; -- distinguishes operational from programming errors Error.captureStackTrace(this, this.constructor); } } export class NotFoundError extends AppError { constructor(resource = "Resource") { super(`${resource} not found`, 404, "NOT_FOUND"); } } export class UnauthorizedError extends AppError { constructor(message = "Authentication required") { super(message, 401, "UNAUTHORIZED"); } } export class ForbiddenError extends AppError { constructor(message = "Access denied") { super(message, 403, "FORBIDDEN"); } } export class ConflictError extends AppError { constructor(message) { super(message, 409, "CONFLICT"); } } -- utils/asyncHandler.js -- Wraps async route handlers to catch rejected promises export const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); -- middleware/errorHandler.js export const errorHandler = (err, req, res, next) => { let statusCode = err.statusCode || 500; let message = err.message; let code = err.code || "INTERNAL_ERROR"; -- Handle Prisma/database errors if (err.code === "P2002") { -- Prisma unique constraint statusCode = 409; message = "A record with that value already exists"; code = "CONFLICT"; } -- Handle JWT errors if (err.name === "JsonWebTokenError") { statusCode = 401; message = "Invalid token"; code = "INVALID_TOKEN"; } -- Log unexpected errors if (statusCode >= 500) { logger.error({ err, req: { method: req.method, url: req.url } }, "Unhandled error"); } res.status(statusCode).json({ error: { message, code }, -- Only include stack in development ...(config.NODE_ENV === "development" && { stack: err.stack }), }); };
Controller, Service, Repository Pattern
javascript-- modules/users/users.controller.js import { asyncHandler } from "../../utils/asyncHandler.js"; import { UsersService } from "./users.service.js"; const usersService = new UsersService(); export const getUser = asyncHandler(async (req, res) => { const user = await usersService.getUserById(req.params.id); res.json({ data: user }); }); export const createUser = asyncHandler(async (req, res) => { const user = await usersService.createUser(req.body); res.status(201).json({ data: user }); }); export const listUsers = asyncHandler(async (req, res) => { const result = await usersService.listUsers(req.query); res.json({ data: result.users, pagination: { page: result.page, limit: result.limit, total: result.total, pages: Math.ceil(result.total / result.limit), }, }); }); -- modules/users/users.service.js import { UsersRepository } from "./users.repository.js"; import { NotFoundError, ConflictError } from "../../utils/AppError.js"; import bcrypt from "bcrypt"; export class UsersService { constructor() { this.repo = new UsersRepository(); } async getUserById(id) { const user = await this.repo.findById(id); if (!user) throw new NotFoundError("User"); return this.sanitize(user); } async createUser(data) { const existing = await this.repo.findByEmail(data.email); if (existing) throw new ConflictError("Email already registered"); const passwordHash = await bcrypt.hash(data.password, 12); const user = await this.repo.create({ ...data, passwordHash }); return this.sanitize(user); } sanitize(user) { const { passwordHash, ...safe } = user; return safe; } }
Structured Logging with Pino
javascript-- utils/logger.js import pino from "pino"; import { config } from "../config/index.js"; export const logger = pino({ level: config.LOG_LEVEL, ...(config.NODE_ENV !== "production" && { transport: { target: "pino-pretty", options: { colorize: true }, }, }), }); -- middleware/requestLogger.js import { randomUUID } from "crypto"; import { logger } from "../utils/logger.js"; export const requestLogger = (req, res, next) => { const requestId = randomUUID(); req.id = requestId; const start = Date.now(); res.on("finish", () => { const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info"; logger[level]({ requestId, method: req.method, url: req.originalUrl, status: res.statusCode, duration: Date.now() - start, userId: req.user?.id, }); }); next(); };
API Versioning
javascript-- app.js import { createRouter as createV1Router } from "./v1/router.js"; import { createRouter as createV2Router } from "./v2/router.js"; app.use("/api/v1", createV1Router()); app.use("/api/v2", createV2Router()); -- Or via header-based versioning app.use((req, res, next) => { const version = req.headers["api-version"] || "1"; req.apiVersion = version; next(); });
Health Check Endpoint
javascriptapp.get("/health", async (req, res) => { const checks = { database: "unknown", redis: "unknown", }; try { await db.query("SELECT 1"); checks.database = "healthy"; } catch { checks.database = "unhealthy"; } try { await redis.ping(); checks.redis = "healthy"; } catch { checks.redis = "unhealthy"; } const isHealthy = Object.values(checks).every(s => s === "healthy"); res.status(isHealthy ? 200 : 503).json({ status: isHealthy ? "healthy" : "degraded", checks, uptime: process.uptime(), timestamp: new Date().toISOString(), }); });
Common Interview Questions
Q: What is the difference between authentication and authorization middleware in Express?
Authentication middleware validates who the user is β it reads the JWT, verifies the signature, and attaches the user to req.user. Authorization middleware checks what the authenticated user is allowed to do β it checks req.user.role against required permissions. Authentication runs first; authorization runs after. If authentication fails, return 401. If authorization fails, return 403.
Q: Why separate the controller, service, and repository layers?
Controllers handle HTTP concerns only (parse request, call service, format response). Services contain business logic (validation, business rules, orchestration) with no knowledge of HTTP. Repositories handle data access (SQL queries, ORM calls) with no business logic. This separation makes each layer independently testable, and allows swapping databases or HTTP frameworks without rewriting business logic.
Q: How do you handle errors in async Express route handlers?
By default, Express does not catch rejected promises in async route handlers β the error is swallowed. You must either wrap each handler with try/catch and call next(err), or use an asyncHandler wrapper that calls .catch(next) on the returned promise. The central error handling middleware then handles all errors consistently.
Practice on Froquiz
Node.js API design is tested in backend developer interviews. Test your Node.js knowledge on Froquiz β covering async, streams, performance, and patterns.
Summary
- Separate concerns: controller (HTTP) β service (business logic) β repository (data access)
- Validate all input at the boundary with Zod β return 422 with field-level errors
- Use a custom
AppErrorclass and centralized error handler β consistent error responses - Wrap async handlers with
asyncHandlerto forward promise rejections to Express error handler - Structured logging (Pino) with request IDs enables tracing across log entries
- Health check endpoint at
/healthenables load balancer and monitoring integration - Version your API from day one β URL versioning (
/api/v1/) is the simplest approach