Building a REST API with Node.js and Express: Complete 2026 Guide
Node.js + Express remains the most popular backend stack for building REST APIs. In 2026, with Express 5 stable and the ecosystem matured, here's the definitive guide to building APIs that are fast, maintainable, and production-ready.
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node ts-node nodemon
npx tsc --init
Directory structure:
src/
routes/
users.ts
products.ts
controllers/
userController.ts
middleware/
auth.ts
errorHandler.ts
models/
User.ts
app.ts
server.ts
The Express App
// src/app.ts
import express, { Application } from 'express';
import userRoutes from './routes/users';
import { errorHandler } from './middleware/errorHandler';
import { notFound } from './middleware/notFound';
const app: Application = express();
// Built-in middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/users', userRoutes);
// 404 handler (must come after routes)
app.use(notFound);
// Error handler (must be last)
app.use(errorHandler);
export default app;
// src/server.ts
import app from './app';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
RESTful Routes
// src/routes/users.ts
import { Router } from 'express';
import {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser,
} from '../controllers/userController';
import { authenticate } from '../middleware/auth';
import { validateUser } from '../middleware/validate';
const router = Router();
router.get('/', authenticate, getUsers);
router.get('/:id', authenticate, getUserById);
router.post('/', validateUser, createUser);
router.put('/:id', authenticate, validateUser, updateUser);
router.delete('/:id', authenticate, deleteUser);
export default router;
Controllers
// src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/userService';
const userService = new UserService();
export async function getUsers(req: Request, res: Response, next: NextFunction) {
try {
const { page = 1, limit = 20 } = req.query;
const users = await userService.findAll({
page: Number(page),
limit: Number(limit),
});
res.json({
success: true,
data: users,
meta: { page: Number(page), limit: Number(limit) },
});
} catch (error) {
next(error); // Pass to error handler
}
}
export async function getUserById(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
}
export async function createUser(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.create(req.body);
res.status(201).json({ success: true, data: user });
} catch (error) {
next(error);
}
}
Middleware
Authentication Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface JwtPayload {
userId: string;
email: string;
}
// Extend Request type
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ success: false, message: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = decoded;
next();
} catch {
res.status(401).json({ success: false, message: 'Invalid token' });
}
}
Validation Middleware (with Zod)
// src/middleware/validate.ts
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['user', 'admin']).optional().default('user'),
});
export function validateUser(req: Request, res: Response, next: NextFunction) {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // Use parsed/coerced data
next();
}
Global Error Handler
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
});
}
// Unexpected errors — log and hide details in production
console.error(err);
res.status(500).json({
success: false,
message: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
}
REST API Best Practices
Use Consistent Response Format
// Success
{ "success": true, "data": { ... }, "meta": { "page": 1, "total": 100 } }
// Error
{ "success": false, "message": "User not found", "errors": { "email": ["Invalid format"] } }
HTTP Status Codes
| Scenario | Status Code |
|---|---|
| Success (GET) | 200 OK |
| Created (POST) | 201 Created |
| No content (DELETE) | 204 No Content |
| Bad request | 400 Bad Request |
| Unauthorized | 401 Unauthorized |
| Forbidden | 403 Forbidden |
| Not found | 404 Not Found |
| Validation error | 422 Unprocessable Entity |
| Server error | 500 Internal Server Error |
Pagination Pattern
// GET /api/users?page=2&limit=20&sort=createdAt&order=desc
router.get('/', async (req, res) => {
const { page = 1, limit = 20, sort = 'createdAt', order = 'desc' } = req.query;
const skip = (Number(page) - 1) * Number(limit);
const [users, total] = await Promise.all([
User.find().sort({ [sort as string]: order as 'asc' | 'desc' }).skip(skip).limit(Number(limit)),
User.countDocuments()
]);
res.json({
success: true,
data: users,
meta: {
page: Number(page),
limit: Number(limit),
total,
totalPages: Math.ceil(total / Number(limit))
}
});
});
Rate Limiting & Security
npm install express-rate-limit helmet cors
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import cors from 'cors';
// Security headers
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));
// Rate limit: 100 requests per 15 minutes
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, message: 'Too many requests, please try again later.' },
});
app.use('/api/', limiter);
Environment Variables
# .env
PORT=3000
NODE_ENV=development
JWT_SECRET=your-super-secret-key-here
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
ALLOWED_ORIGINS=http://localhost:5173,https://myapp.com
// Load with dotenv
import dotenv from 'dotenv';
dotenv.config(); // Must be called before accessing process.env
Testing Your API
# Quick tests with curl
curl -X GET http://localhost:3000/api/users
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"john@example.com","password":"secret123"}'
# With auth token
curl -X GET http://localhost:3000/api/users \
-H "Authorization: Bearer eyJhbGci..."
Summary
Key takeaways for production Node.js APIs:
- Separate concerns: routes → controllers → services → models
- Always use middleware for auth, validation, error handling
- Global error handler catches everything — don't let errors slip
- Validate all inputs with Zod or Joi before processing
- Use environment variables — never hardcode secrets
- Add rate limiting and security headers before going to production
- Consistent response format makes client-side code simpler
→ Test your API responses with JSON Viewer and parse JWTs with JWT Parser.