Node.js API Best Practices 2026 — Build Production-Ready REST APIs

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Node.js remains the most-deployed runtime for REST APIs in 2026. But most tutorials stop at "it works" — this guide covers what separates a working API from a production API: validation, structured errors, rate limiting, logging, and health checks.

Project Setup with Fastify

Fastify outperforms Express significantly with built-in schema validation:

npm init -y
npm install fastify @fastify/cors @fastify/rate-limit @fastify/jwt zod
npm install -D typescript @types/node tsx
// src/server.ts
import Fastify from 'fastify'
import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'
import jwt from '@fastify/jwt'
 
export const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL ?? 'info',
    transport:
      process.env.NODE_ENV === 'development'
        ? { target: 'pino-pretty' }
        : undefined,
  },
})
 
await app.register(cors, { origin: process.env.CORS_ORIGIN })
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' })
await app.register(jwt, { secret: process.env.JWT_SECRET! })
 
app.addHook('onRequest', async (request, reply) => {
  request.log.info({ url: request.url, method: request.method }, 'incoming')
})

Input Validation with Zod

Never trust incoming data — validate at the boundary:

// src/schemas/user.ts
import { z } from 'zod'
 
export const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  role: z.enum(['admin', 'user', 'viewer']).default('user'),
  age: z.number().int().min(13).max(120).optional(),
})
 
export const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(['asc', 'desc']).default('desc'),
})
 
export type CreateUserInput = z.infer<typeof CreateUserSchema>
export type PaginationInput = z.infer<typeof PaginationSchema>
// src/routes/users.ts
import { app } from '../server'
import { CreateUserSchema, PaginationSchema } from '../schemas/user'
import { db } from '../lib/db'
 
app.post('/users', async (request, reply) => {
  const parsed = CreateUserSchema.safeParse(request.body)
  if (!parsed.success) {
    return reply.status(400).send({
      error: 'Validation failed',
      details: parsed.error.flatten().fieldErrors,
    })
  }
 
  const user = await db.user.create({ data: parsed.data })
  return reply.status(201).send({ data: user })
})
 
app.get('/users', async (request, reply) => {
  const query = PaginationSchema.parse(request.query)
  const [users, total] = await Promise.all([
    db.user.findMany({
      skip: (query.page - 1) * query.limit,
      take: query.limit,
      orderBy: { createdAt: query.sort },
    }),
    db.user.count(),
  ])
 
  return {
    data: users,
    meta: {
      page: query.page,
      limit: query.limit,
      total,
      pages: Math.ceil(total / query.limit),
    },
  }
})

Structured Error Handling

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    public readonly message: string,
    public readonly statusCode: number = 500,
    public readonly code: string = 'INTERNAL_ERROR',
    public readonly details?: unknown
  ) {
    super(message)
    this.name = 'AppError'
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`, 404, 'NOT_FOUND')
  }
}
 
export class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED')
  }
}
// Global error handler
app.setErrorHandler((error, request, reply) => {
  request.log.error(error)
 
  if (error instanceof AppError) {
    return reply.status(error.statusCode).send({
      error: { code: error.code, message: error.message, details: error.details },
    })
  }
 
  return reply.status(500).send({
    error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
  })
})

Authentication Middleware

// src/middleware/auth.ts
import { FastifyRequest, FastifyReply } from 'fastify'
 
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
  try {
    await request.jwtVerify()
  } catch {
    throw new UnauthorizedError()
  }
}
 
// Attach to route
app.get('/me', { preHandler: [requireAuth] }, async (request) => {
  const user = await db.user.findUnique({ where: { id: request.user.id } })
  if (!user) throw new NotFoundError('User', request.user.id)
  return { data: user }
})

Health Checks and Observability

// src/routes/health.ts
app.get('/health', async () => {
  const dbHealthy = await db.$queryRaw`SELECT 1`.then(() => true).catch(() => false)
  const status = dbHealthy ? 'ok' : 'degraded'
 
  return {
    status,
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    checks: { database: dbHealthy ? 'ok' : 'failed' },
  }
})
 
// Graceful shutdown
const signals = ['SIGTERM', 'SIGINT'] as const
for (const signal of signals) {
  process.on(signal, async () => {
    app.log.info(`Received ${signal}, shutting down gracefully`)
    await app.close()
    await db.$disconnect()
    process.exit(0)
  })
}

Common Mistakes

  • Using req.body without validation — always parse with Zod or a schema library first
  • Returning raw database errors to clients — always map to structured AppError responses
  • Not setting a request timeout — a hung database query should not hang the entire server
  • Logging sensitive fields like passwords or tokens — use a log serializer to redact them
  • Skipping graceful shutdown — in-flight requests get dropped when the process exits abruptly

Best Practices

  • Use Fastify over Express for new projects — 2x throughput and built-in schema support
  • Validate all inputs (body, query, params) at the route handler boundary with Zod
  • Return consistent error shapes: { error: { code, message, details } } across all endpoints
  • Implement /health with liveness and readiness checks for Kubernetes deployments
  • Use structured JSON logging (pino) with request IDs for distributed tracing

Key Takeaways

  • Fastify is 2x faster than Express and includes built-in schema validation and serialization
  • Zod validation at the API boundary catches bad data before it reaches the database layer
  • A custom AppError hierarchy maps internal errors to consistent HTTP status codes
  • Rate limiting at the framework level prevents abuse without an API gateway
  • Graceful shutdown handling prevents dropped requests during deployments
  • Structured logging with pino enables searchable, filterable log ingestion in production
  • /health endpoints should check all dependencies (DB, cache) and return degraded status
  • JWT verification in a preHandler hook protects routes without duplicating auth logic

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading