Node.js API Best Practices 2026 — Build Production-Ready REST APIs
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.bodywithout 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
/healthwith 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
AppErrorhierarchy 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
/healthendpoints should check all dependencies (DB, cache) and return degraded status- JWT verification in a
preHandlerhook protects routes without duplicating auth logic
Advertisement
Related reading
API Versioning in Node.js and TypeScript — Complete 2026 Guide5 min readJWT Authentication with TypeScript — Secure Implementation 20266 min readPassport.js with TypeScript — Authentication Strategies Guide 20265 min readRate Limiting in Node.js — Protect Your API from Abuse 20266 min readSwagger and OpenAPI 3.1 in Node.js — API Documentation Guide 20265 min readZod Validation with TypeScript — Complete Guide 20265 min read