REST APIs need consistent patterns for validation, authentication, error handling, and response formatting - here's what actually works in production.
Building an Express REST API is easy. Building one that doesn't get you yelled at by senior engineers when they see your route handlers is harder. After reviewing enough APIs that made me question life choices, here are the patterns that actually work.
Request Validation - Stop Trusting User Input
Your users will send garbage. Malicious garbage, accidentally garbage, creatively garbage. Never trust request data, period. I've seen production APIs broken by users sending null
where they should send strings, arrays where they should send objects, and SQL injection attempts disguised as user feedback.
Zod vs Joi vs express-validator - The Real Comparison
Here's what actually matters after using all three in production:
Zod (2025 favorite):
- TypeScript-first design with excellent type inference
- 40% smaller bundle size than Joi
- Runtime type safety matches compile-time types
- Growing fast - 5M+ weekly downloads
Joi (Production workhorse):
- Battle-tested in production for years
- Rich ecosystem with extensions
- Slightly heavier but more features
- 8M+ weekly downloads - still dominant
express-validator (Simple choice):
- Built specifically for Express
- Lighter learning curve
- Less type safety but faster setup
- Good for simple validation needs (until you need real TypeScript integration)
Zod validation that doesn't make you cry:
const { z } = require('zod');
// Define schemas that catch real-world edge cases
const CreateUserSchema = z.object({
email: z.string()
.email('Invalid email format')
.max(255, 'Email too long')
.toLowerCase()
.trim(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password too long (max 128 chars)')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must contain uppercase, lowercase, and number'),
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long')
.regex(/^[a-zA-Z\s]+$/, 'Name can only contain letters and spaces')
.trim(),
age: z.number()
.int('Age must be a whole number')
.min(13, 'Must be at least 13 years old')
.max(120, 'Invalid age')
.optional(),
// Handle arrays properly
tags: z.array(z.string().max(50, 'Tag too long'))
.max(10, 'Maximum 10 tags allowed')
.optional()
.default([])
});
// Validation middleware that doesn't suck
const validateRequest = (schema) => {
return (req, res, next) => {
try {
// Parse and validate - this transforms the data too
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
received: err.received
}));
return res.status(400).json({
error: 'Validation failed',
details: formattedErrors
});
}
next(error);
}
};
};
// Usage - clean and type-safe
app.post('/api/users', validateRequest(CreateUserSchema), async (req, res) => {
// req.body is now validated and transformed
const user = await User.create(req.body);
res.status(201).json({
id: user.id,
email: user.email,
name: user.name
});
});
Advanced Validation Patterns
Real APIs need more than basic field validation:
// Custom validation for business rules
const UpdateUserSchema = z.object({
email: z.string().email().optional(),
currentPassword: z.string().optional(),
newPassword: z.string().min(8).optional()
}).refine(data => {
// If changing password, current password is required
if (data.newPassword && !data.currentPassword) {
return false;
}
return true;
}, {
message: 'Current password required when changing password',
path: ['currentPassword']
});
// File upload validation
const FileUploadSchema = z.object({
file: z.object({
mimetype: z.enum(['image/jpeg', 'image/png', 'image/gif'], {
errorMap: () => ({ message: 'Only JPEG, PNG, and GIF images allowed' })
}),
size: z.number().max(5 * 1024 * 1024, 'File too large (max 5MB)')
})
});
// Query parameter validation
const GetUsersSchema = z.object({
page: z.string()
.regex(/^\d+$/, 'Page must be a number')
.transform(Number)
.refine(val => val >= 1, 'Page must be at least 1')
.optional()
.default('1'),
limit: z.string()
.regex(/^\d+$/, 'Limit must be a number')
.transform(Number)
.refine(val => val >= 1 && val <= 100, 'Limit must be between 1 and 100')
.optional()
.default('20'),
search: z.string()
.min(2, 'Search must be at least 2 characters')
.max(100, 'Search too long')
.trim()
.optional()
});
app.get('/api/users', validateQuery(GetUsersSchema), async (req, res) => {
const { page, limit, search } = req.query;
const users = await User.findPaginated({ page, limit, search });
res.json(users);
});
More validation patterns? The Zod docs and Express validation guides have some decent examples.
JWT Authentication That Doesn't Leak Secrets
JWT authentication involves access tokens, refresh tokens, secure storage, and proper validation - get any part wrong and you'll leak user data.
JWT authentication is everywhere because it's stateless and scales well. It's also everywhere because developers implement it wrong and create security disasters. Here's how to do JWT auth without getting fired:
Secure JWT Implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const rateLimit = require('express-rate-limit');
// Rate limiting for auth endpoints - critical for security
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many login attempts, try again later',
standardHeaders: true,
legacyHeaders: false
});
// Secure JWT configuration
const jwtConfig = {
accessTokenSecret: process.env.JWT_ACCESS_SECRET, // Strong random secret
refreshTokenSecret: process.env.JWT_REFRESH_SECRET, // Different secret
accessTokenExpiry: '15m', // Short-lived access tokens
refreshTokenExpiry: '7d' // Longer refresh tokens
};
// Token generation with proper claims
const generateTokens = (user) => {
const payload = {
sub: user.id.toString(), // Subject - user ID
email: user.email,
role: user.role,
iat: Math.floor(Date.now() / 1000), // Issued at
iss: 'your-api-name' // Issuer
};
const accessToken = jwt.sign(payload, jwtConfig.accessTokenSecret, {
expiresIn: jwtConfig.accessTokenExpiry,
algorithm: 'HS256'
});
const refreshToken = jwt.sign(
{ sub: user.id.toString(), type: 'refresh' },
jwtConfig.refreshTokenSecret,
{
expiresIn: jwtConfig.refreshTokenExpiry,
algorithm: 'HS256'
}
);
return { accessToken, refreshToken };
};
// Secure login endpoint
app.post('/api/auth/login', authLimiter, async (req, res) => {
try {
const { email, password } = req.body;
// Find user and include password for verification
// Fun fact: Sequelize will cache this query and fuck you over in production
const user = await User.findOne({
where: { email: email.toLowerCase() },
include: ['password_hash']
});
if (!user || !await bcrypt.compare(password, user.password_hash)) {
// Don't reveal whether email exists
return res.status(401).json({
error: 'Invalid email or password'
});
}
// Check if account is active
if (!user.is_active) {
return res.status(401).json({
error: 'Account is disabled'
});
}
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token hash in database
const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
await user.update({
refresh_token_hash: refreshTokenHash,
last_login: new Date()
});
// Set secure HTTP-only cookie for refresh token
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Return access token in response body
res.json({
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
},
expiresIn: jwtConfig.accessTokenExpiry
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
JWT Authentication Middleware
// JWT verification middleware with proper error handling
const authenticateJWT = (options = {}) => {
return async (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: null;
if (!token) {
if (options.optional) {
req.user = null;
return next();
}
return res.status(401).json({
error: 'Access token required',
code: 'TOKEN_MISSING'
});
}
// Verify token
const decoded = jwt.verify(token, jwtConfig.accessTokenSecret);
// Optional: Check if user still exists and is active
if (options.checkUser) {
const user = await User.findByPk(decoded.sub);
if (!user || !user.is_active) {
return res.status(401).json({
error: 'Invalid token',
code: 'USER_NOT_FOUND'
});
}
req.user = user;
} else {
req.user = decoded;
}
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
error: 'Invalid token',
code: 'TOKEN_INVALID'
});
}
console.error('JWT verification error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
};
};
// Authorization middleware for role-based access
const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const userRoles = Array.isArray(req.user.role) ? req.user.role : [req.user.role];
const requiredRoles = Array.isArray(roles) ? roles : [roles];
const hasRole = requiredRoles.some(role => userRoles.includes(role));
if (!hasRole) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredRoles,
current: userRoles
});
}
next();
};
};
// Usage examples
app.get('/api/profile',
authenticateJWT({ checkUser: true }),
getUserProfile
);
app.delete('/api/users/:id',
authenticateJWT({ checkUser: true }),
requireRole(['admin', 'moderator']),
deleteUser
);
app.get('/api/public',
authenticateJWT({ optional: true }),
getPublicData // Works with or without auth
);
Token Refresh Pattern
// Token refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({
error: 'Refresh token required',
code: 'REFRESH_TOKEN_MISSING'
});
}
// Verify refresh token
const decoded = jwt.verify(refreshToken, jwtConfig.refreshTokenSecret);
// Find user and verify stored refresh token
const user = await User.findByPk(decoded.sub);
if (!user || !user.refresh_token_hash) {
return res.status(401).json({
error: 'Invalid refresh token',
code: 'REFRESH_TOKEN_INVALID'
});
}
// Verify refresh token hash
const isValidRefresh = await bcrypt.compare(refreshToken, user.refresh_token_hash);
if (!isValidRefresh) {
return res.status(401).json({
error: 'Invalid refresh token',
code: 'REFRESH_TOKEN_INVALID'
});
}
// Generate new tokens
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user);
// Update stored refresh token
const newRefreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
await user.update({ refresh_token_hash: newRefreshTokenHash });
// Set new refresh token cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({
accessToken,
expiresIn: jwtConfig.accessTokenExpiry
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Refresh token expired',
code: 'REFRESH_TOKEN_EXPIRED'
});
}
console.error('Token refresh error:', error);
res.status(500).json({ error: 'Token refresh failed' });
}
});
// Logout endpoint - invalidate refresh token
app.post('/api/auth/logout', authenticateJWT(), async (req, res) => {
try {
const user = await User.findByPk(req.user.sub);
if (user) {
await user.update({ refresh_token_hash: null });
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
Want to learn more about not fucking up JWT? Auth0's security best practices, Node.js JWT security guide, and OWASP JWT guidelines are solid.
Error Handling That Actually Helps Debugging
Bad error responses waste hours of debugging time. Good error responses prevent support tickets and angry users. Here's how to build error handling that helps instead of hurts:
Structured Error Responses
// Error response factory
const createErrorResponse = (type, message, details = null, code = null) => {
const error = {
error: message,
type,
timestamp: new Date().toISOString(),
requestId: req.requestId || 'unknown'
};
if (code) error.code = code;
if (details && process.env.NODE_ENV !== 'production') {
error.details = details;
}
return error;
};
// Centralized error handling middleware
const errorHandler = (err, req, res, next) => {
// Log error with context
console.error('API Error:', {
error: err.message,
stack: err.stack,
requestId: req.requestId,
url: req.originalUrl,
method: req.method,
body: req.body,
params: req.params,
query: req.query,
userAgent: req.get('User-Agent'),
ip: req.ip,
timestamp: new Date().toISOString()
});
// Handle different error types
if (err.name === 'ValidationError') {
return res.status(400).json(
createErrorResponse('VALIDATION_ERROR', 'Invalid request data', err.details)
);
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json(
createErrorResponse('UNAUTHORIZED', 'Authentication failed', null, 'AUTH_FAILED')
);
}
if (err.name === 'ForbiddenError') {
return res.status(403).json(
createErrorResponse('FORBIDDEN', 'Insufficient permissions', null, 'PERMISSION_DENIED')
);
}
if (err.name === 'NotFoundError') {
return res.status(404).json(
createErrorResponse('NOT_FOUND', 'Resource not found', null, 'RESOURCE_NOT_FOUND')
);
}
if (err.code === 'ECONNREFUSED') {
return res.status(503).json(
createErrorResponse('SERVICE_UNAVAILABLE', 'Database connection failed', null, 'DB_UNAVAILABLE')
);
}
// Database constraint violations
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json(
createErrorResponse('CONFLICT', 'Resource already exists', err.errors)
);
}
if (err.name === 'SequelizeForeignKeyConstraintError') {
return res.status(400).json(
createErrorResponse('INVALID_REFERENCE', 'Referenced resource does not exist')
);
}
// Default internal server error
res.status(500).json(
createErrorResponse(
'INTERNAL_ERROR',
process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message
)
);
};
// Custom error classes for better error handling
class APIError extends Error {
constructor(message, statusCode = 500, type = 'API_ERROR', code = null) {
super(message);
this.name = 'APIError';
this.statusCode = statusCode;
this.type = type;
this.code = code;
}
}
class ValidationError extends APIError {
constructor(message, details = null) {
super(message, 400, 'VALIDATION_ERROR');
this.name = 'ValidationError';
this.details = details;
}
}
class NotFoundError extends APIError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND', 'RESOURCE_NOT_FOUND');
this.name = 'NotFoundError';
}
}
class UnauthorizedError extends APIError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED', 'AUTH_REQUIRED');
this.name = 'UnauthorizedError';
}
}
// Usage in route handlers
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findByPk(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
} catch (error) {
next(error);
}
});
app.post('/api/posts', authenticateJWT(), async (req, res, next) => {
try {
if (!req.body.title || req.body.title.trim().length === 0) {
throw new ValidationError('Title is required', { field: 'title' });
}
const post = await Post.create({
...req.body,
userId: req.user.sub
});
res.status(201).json(post);
} catch (error) {
next(error);
}
});
More error handling patterns? The Express error guide, Node.js error docs, and JSON API error standards have decent examples.
REST API Structure That Scales
Good API structure prevents technical debt and makes your API pleasant to use. Bad structure leads to inconsistent endpoints, duplicated logic, and developers cursing your name:
Resource-Based URL Design
// Good REST URL patterns
GET /api/users // List users
POST /api/users // Create user
GET /api/users/:id // Get specific user
PUT /api/users/:id // Update user (full replacement)
PATCH /api/users/:id // Partial update user
DELETE /api/users/:id // Delete user
// Nested resources
GET /api/users/:id/posts // User's posts
POST /api/users/:id/posts // Create post for user
GET /api/posts/:id/comments // Comments for a post
// Avoid deep nesting (> 2 levels)
// Bad: /api/users/:id/posts/:postId/comments/:commentId/replies
// Good: /api/comments/:id/replies
// Query parameters for filtering/sorting
GET /api/posts?author=123&published=true&sort=-created_at&page=2&limit=20
Consistent Response Formats
// Success responses with metadata
const sendSuccessResponse = (res, data, metadata = {}) => {
const response = {
success: true,
data,
...metadata
};
if (metadata.pagination) {
response.pagination = metadata.pagination;
}
res.json(response);
};
// Paginated list responses
app.get('/api/posts', async (req, res, next) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const { rows: posts, count: totalItems } = await Post.findAndCountAll({
limit,
offset,
order: [['created_at', 'DESC']],
include: [{ model: User, attributes: ['id', 'name'] }]
});
sendSuccessResponse(res, posts, {
pagination: {
currentPage: page,
totalItems,
totalPages: Math.ceil(totalItems / limit),
itemsPerPage: limit,
hasNextPage: page < Math.ceil(totalItems / limit),
hasPreviousPage: page > 1
}
});
} catch (error) {
next(error);
}
});
// Single resource response
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findByPk(req.params.id, {
attributes: { exclude: ['password_hash'] }
});
if (!user) {
throw new NotFoundError('User');
}
sendSuccessResponse(res, user);
} catch (error) {
next(error);
}
});
// Creation response with location header
app.post('/api/users', async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201)
.location(`/api/users/${user.id}`)
.json({
success: true,
data: user,
message: 'User created successfully'
});
} catch (error) {
next(error);
}
});
That covers the main patterns for building Express APIs that won't embarrass you. Focus on consistency, don't leak errors, secure your auth properly, and validate everything users send you because they will definitely try to break your shit.