📜 Table of Contents
Building a REST API with Node.js and Express is one of the most foundational skills in modern backend development. In 2026, Express.js remains the most widely deployed Node.js web framework — powering APIs at companies of every size, from startups to enterprise — due to its simplicity, flexibility, and the enormous ecosystem of middleware and tooling built around it. Whether you are building a backend for a mobile app, a microservice, or a data API, the patterns covered in this guide form the backbone of professional Node.js API development.
This step-by-step guide takes you from project initialization through to a production-ready API, covering routing, middleware, request validation, authentication, error handling, database integration, and deployment. All code examples reflect 2026 best practices using modern JavaScript (ESM) and the current Express and Node.js LTS versions.
Prerequisites and Project Setup
Before building the API, you need Node.js LTS (22.x in 2026) installed. Verify your installation with node –version and npm –version. Create a project directory and initialize it:
mkdir my-rest-api
cd my-rest-api
npm init -y
Install the core dependencies. Express is your framework; dotenv manages environment variables; cors handles Cross-Origin Resource Sharing headers; helmet adds security headers:
npm install express dotenv cors helmet
npm install --save-dev nodemon
Set up your package.json to use ES Modules and configure the development server script:
{
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
}
}
Create a .env file in the project root for your environment configuration:
PORT=3000
NODE_ENV=development
JWT_SECRET=your-super-secret-key-change-in-production
DATABASE_URL=your-database-connection-string
Building the Express Application Entry Point
Create the src/index.js file — this is the entry point for your application. Structure it to separate the app configuration from the server startup, which makes testing significantly easier:
// src/index.js
import 'dotenv/config';
import app from './app.js';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
Create src/app.js for the Express application configuration:
// src/app.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import userRoutes from './routes/users.js';
import { errorHandler } from './middleware/errorHandler.js';
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/v1/users', userRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Global error handler (must be last)
app.use(errorHandler);
export default app;
REST API Design Principles
Before writing route handlers, understanding REST conventions ensures your API is intuitive and consistent. REST (Representational State Transfer) APIs organize resources as nouns (users, products, orders) and use HTTP methods (verbs) to describe actions on those resources. The standard mapping is: GET /resources to list all resources; GET /resources/:id to retrieve one; POST /resources to create a new resource; PUT /resources/:id to replace a resource entirely; PATCH /resources/:id to partially update a resource; DELETE /resources/:id to delete a resource. Use plural nouns for resource names, nest related resources logically (/users/:id/posts), use HTTP status codes correctly (200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 Unprocessable Entity, 500 Internal Server Error), and version your API in the URL (/api/v1/) to allow breaking changes without disrupting existing consumers.
Creating Route Handlers
Organize your routes in a dedicated routes directory with one file per resource. Create src/routes/users.js:
// src/routes/users.js
import { Router } from 'express';
import {
getUsers,
getUserById,
createUser,
updateUser,
deleteUser
} from '../controllers/userController.js';
import { authenticate } from '../middleware/auth.js';
import { validateUser } from '../middleware/validation.js';
const router = Router();
router.get('/', authenticate, getUsers);
router.get('/:id', authenticate, getUserById);
router.post('/', validateUser, createUser);
router.patch('/:id', authenticate, validateUser, updateUser);
router.delete('/:id', authenticate, deleteUser);
export default router;
Create the corresponding controller in src/controllers/userController.js. Controllers handle the business logic and call service or database functions:
// src/controllers/userController.js
import { asyncHandler } from '../middleware/asyncHandler.js';
import * as userService from '../services/userService.js';
export const getUsers = asyncHandler(async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const users = await userService.findAll({ page: Number(page), limit: Number(limit) });
res.json({ data: users, page: Number(page), limit: Number(limit) });
});
export const getUserById = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: user });
});
export const createUser = asyncHandler(async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json({ data: user });
});
export const updateUser = asyncHandler(async (req, res) => {
const user = await userService.update(req.params.id, req.body);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: user });
});
export const deleteUser = asyncHandler(async (req, res) => {
await userService.remove(req.params.id);
res.status(204).send();
});
Essential Middleware: Error Handling and Async Wrapper
Express’s error handling requires that errors be passed to the next() function. Create a reusable asyncHandler wrapper that catches promise rejections and forwards them to your error middleware, eliminating try-catch blocks from every route handler:
// src/middleware/asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
Create the global error handler that catches all errors passed to next(err):
// src/middleware/errorHandler.js
export const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Log errors in development, but not validation errors in production
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
res.status(statusCode).json({
error: message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
Authentication with JWT
JWT (JSON Web Token) authentication is the standard for REST APIs. Install the required package:
npm install jsonwebtoken bcryptjs
Create the authentication middleware:
// src/middleware/auth.js
import jwt from 'jsonwebtoken';
export const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
Create a login endpoint that issues JWT tokens in src/routes/auth.js:
// src/routes/auth.js (simplified)
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { asyncHandler } from '../middleware/asyncHandler.js';
const router = Router();
router.post('/login', asyncHandler(async (req, res) => {
const { email, password } = req.body;
// In a real implementation, look up the user from your database
const user = await findUserByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, email: user.email } });
}));
export default router;
Input Validation
Never trust user input. Validate and sanitize all incoming request data before it reaches your business logic. In 2026, Zod is the preferred validation library for Node.js due to its TypeScript-first design and excellent developer experience:
npm install zod
// src/middleware/validation.js
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).optional(),
role: z.enum(['user', 'admin']).optional()
});
export const validateUser = (req, res, next) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors
});
}
req.body = result.data; // Use the parsed (and sanitized) data
next();
};
Rate Limiting and Security Best Practices
Protect your API from abuse with rate limiting:
npm install express-rate-limit
// Add to src/app.js
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
app.use('/api/', limiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts, please try again later' }
});
app.use('/api/v1/auth/login', authLimiter);
Deployment Considerations
For production deployment in 2026, containerize your API with Docker for consistent environments across development, staging, and production. A minimal Dockerfile for a Node.js Express API:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]
Use a process manager like PM2 for non-containerized deployments. Set NODE_ENV=production to disable development error messages. Use environment variable management tools (AWS Secrets Manager, HashiCorp Vault, or your platform’s native secrets management) rather than .env files in production. Implement structured logging (with a library like Pino) rather than console.log for production observability. Set up health check endpoints and monitor them with an uptime monitoring service.
Conclusion
Building a production-ready REST API with Node.js and Express involves more than just defining routes — it requires a thoughtful architecture that separates concerns (routes, controllers, services, middleware), robust error handling that catches both synchronous and asynchronous errors, input validation that prevents malformed data from reaching business logic, JWT authentication that secures protected endpoints, and rate limiting that prevents abuse. The patterns in this guide — asyncHandler wrapper, centralized error middleware, Zod validation, JWT auth middleware — are directly applicable to production APIs and form the backbone of professional Node.js development in 2026. Start with this foundation, add your specific business logic in the service layer, integrate your chosen database, and deploy with confidence.
❓ Frequently Asked Questions
🚀 Keep Exploring
Discover more articles, guides, and tools in Web Development