FroquizFroquiz
HomeQuizzesSenior ChallengeGet CertifiedBlogAbout
Sign InStart Quiz
Sign InStart Quiz
Froquiz

The most comprehensive quiz platform for software engineers. Test yourself with 10000+ questions and advance your career.

LinkedIn

Platform

  • Start Quizzes
  • Topics
  • Blog
  • My Profile
  • Sign In

About

  • About Us
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

Β© 2026 Froquiz. All rights reserved.Built with passion for technology
Blog & Articles

Node.js REST API Best Practices: Structure, Validation, Error Handling and Security

Build production-ready REST APIs with Node.js. Covers project structure, input validation with Zod, centralized error handling, authentication middleware, logging, and API versioning.

Yusuf SeyitoğluMarch 18, 20263 views10 min read

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:

code
src/ β”œβ”€β”€ 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

javascript
app.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 AppError class and centralized error handler β€” consistent error responses
  • Wrap async handlers with asyncHandler to forward promise rejections to Express error handler
  • Structured logging (Pino) with request IDs enables tracing across log entries
  • Health check endpoint at /health enables load balancer and monitoring integration
  • Version your API from day one β€” URL versioning (/api/v1/) is the simplest approach

About Author

Yusuf Seyitoğlu

Author β†’

Other Posts

  • Python Concurrency: Threading, Multiprocessing and the GIL ExplainedMar 18
  • Java Design Patterns: Singleton, Factory, Builder, Observer, Strategy and MoreMar 18
  • CSS Architecture: BEM, SMACSS, Utility-First and Modern ApproachesMar 18
All Blogs