When I build Node.js applications, the API is where everything comes together. It’s the handshake between the frontend and the backend. Over the years, I’ve learned that good API design isn’t about being clever. It’s about being clear, reliable, and kind to the developers who will use it. I want to share with you some patterns that have helped me build APIs that stand up to real use.
Let’s start with RESTful principles. Think of your data as resources, like users or products. Each resource gets its own address on the web. When you want to do something with that resource, you use simple HTTP verbs. GET to read, POST to create, PUT to update, DELETE to remove. This consistency makes your API predictable. Developers can guess how to use it without reading endless documentation.
I structure my Express applications around these resources. Each endpoint maps directly to a resource operation. The path /api/users represents the collection of users. Adding /api/users/:id points to one specific user. The HTTP method tells the server what I want to do with it. This approach uses the web’s built-in language, making the API feel familiar.
// A practical RESTful setup in Express
const express = require('express');
const app = express();
app.use(express.json()); // So we can read JSON bodies
// Getting all users
app.get('/api/users', async (req, res) => {
try {
const users = await UserModel.find({});
res.json(users);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch users' });
}
});
// Getting one user by ID
app.get('/api/users/:userId', async (req, res) => {
try {
const user = await UserModel.findById(req.params.userId);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch user' });
}
});
// Creating a new user
app.post('/api/users', async (req, res) => {
try {
const newUser = new UserModel(req.body);
const savedUser = await newUser.save();
res.status(201).json(savedUser); // 201 means "Created"
} catch (error) {
res.status(400).json({ message: 'Failed to create user', error: error.message });
}
});
// Updating an existing user
app.put('/api/users/:userId', async (req, res) => {
try {
const updatedUser = await UserModel.findByIdAndUpdate(
req.params.userId,
req.body,
{ new: true, runValidators: true } // Return the updated document
);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.json(updatedUser);
} catch (error) {
res.status(400).json({ message: 'Failed to update user' });
}
});
// Deleting a user
app.delete('/api/users/:userId', async (req, res) => {
try {
const deletedUser = await UserModel.findByIdAndDelete(req.params.userId);
if (!deletedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.status(204).send(); // 204 means "No Content" - successful, but nothing to return
} catch (error) {
res.status(500).json({ message: 'Failed to delete user' });
}
});
The next pattern I rely on is middleware. Middleware functions are like checkpoints a request passes through. Each one has a specific job. One checks if the user is logged in. Another makes sure the data is in the right format. They run in order before your main route logic does its work. This keeps your route handlers clean and focused on their main task.
I write middleware for common tasks. Authentication is a big one. Logging is another. So is rate limiting, which stops someone from flooding your API with too many requests. By breaking these concerns into separate functions, I can reuse them across many routes. It makes the application easier to understand and change.
// Building custom middleware functions
const logRequest = (req, res, next) => {
const start = Date.now();
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} from ${req.ip}`);
// Listen for when the response finishes
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`);
});
next(); // Pass control to the next middleware or route
};
// A simple authentication check
const checkApiKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
const validKey = process.env.API_KEY;
if (!apiKey || apiKey !== validKey) {
return res.status(401).json({ message: 'Valid API key required' });
}
next();
};
// Applying middleware in Express
app.use(logRequest); // Log all requests
app.use('/api/admin', checkApiKey); // Only check API key for admin routes
// Now the routes are cleaner
app.get('/api/admin/stats', async (req, res) => {
// We know the request is logged and authenticated
const stats = await getApplicationStats();
res.json(stats);
});
Authentication and authorization are different but related. Authentication answers “Who are you?” Authorization asks “Are you allowed to do that?” I often use JSON Web Tokens (JWT) for authentication. When a user logs in, my server gives them a signed token. They send that token back with every request. My server can verify it without needing to check the database every time.
Authorization is about permissions. Maybe a regular user can update their own profile, but only an admin can delete other users. I handle this with role checks or by comparing the user ID in the token to the resource they’re trying to access. Keeping these two concepts separate in my code makes security easier to manage.
// JWT Authentication setup
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// Helper to generate a token
const generateToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '7d' } // Token expires in 7 days
);
};
// Login route
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// 1. Find the user
const user = await UserModel.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// 2. Check the password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// 3. Generate a token
const token = generateToken(user._id);
// 4. Send the token and safe user info back
res.json({
token,
user: {
id: user._id,
email: user.email,
name: user.name
}
});
});
// Middleware to verify the token on protected routes
const verifyToken = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Authentication required' });
}
const token = authHeader.split(' ')[1]; // Get the part after "Bearer"
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId; // Attach the user ID to the request
next();
} catch (error) {
return res.status(401).json({ message: 'Invalid or expired token' });
}
};
// Authorization: Check if the user owns the resource
const authorizeUser = (req, res, next) => {
const resourceUserId = req.params.userId; // From the URL
if (req.userId !== resourceUserId) {
return res.status(403).json({ message: 'You do not have permission to access this resource' });
}
next();
};
// Using both on a route
app.put('/api/users/:userId/profile',
verifyToken, // First, make sure they are logged in
authorizeUser, // Then, make sure they are updating their own profile
async (req, res) => {
// Safe to update
const updatedUser = await UserModel.findByIdAndUpdate(
req.userId,
req.body,
{ new: true }
);
res.json(updatedUser);
}
);
Never trust data from the outside world. This is a rule I live by. Validation is the process of checking that incoming data is correct before you use it. Is that email actually an email address? Is the age a positive number? I use libraries to define rules, or schemas, for each piece of data. If the data doesn’t fit the schema, I reject the request immediately with a clear error message.
This does two important things. First, it protects my database from garbage data. Second, it gives the client developer instant feedback on what they did wrong. Good validation error messages are a form of documentation. They teach developers how to use your API correctly.
// Data validation with Joi
const Joi = require('joi');
// Define what a valid product looks like
const productSchema = Joi.object({
name: Joi.string().min(3).max(100).required(),
description: Joi.string().max(1000),
price: Joi.number().positive().precision(2).required(),
category: Joi.string().valid('electronics', 'clothing', 'books', 'home').required(),
inStock: Joi.boolean().default(true),
tags: Joi.array().items(Joi.string()).max(10)
});
// Middleware that uses the schema
const validateBody = (schema) => {
return (req, res, next) => {
const result = schema.validate(req.body, {
abortEarly: false // Collect all errors, not just the first one
});
if (result.error) {
// Format a nice error response
const errorDetails = result.error.details.map(detail => {
return {
field: detail.path.join('.'),
message: detail.message
};
});
return res.status(400).json({
message: 'Validation failed',
errors: errorDetails
});
}
// If validation passed, replace the body with the cleaned data
req.body = result.value;
next();
};
};
// Using it in a route
app.post('/api/products',
validateBody(productSchema),
async (req, res) => {
// At this point, we know req.body is valid
const newProduct = new ProductModel(req.body);
await newProduct.save();
res.status(201).json(newProduct);
}
);
// Different schema for updates (maybe all fields are optional)
const productUpdateSchema = Joi.object({
name: Joi.string().min(3).max(100),
description: Joi.string().max(1000),
price: Joi.number().positive().precision(2),
inStock: Joi.boolean()
}).min(1); // Must have at least one field to update
app.patch('/api/products/:productId',
validateBody(productUpdateSchema),
async (req, res) => {
const updated = await ProductModel.findByIdAndUpdate(
req.params.productId,
req.body,
{ new: true }
);
res.json(updated);
}
);
What happens when you have ten thousand products? You don’t send them all at once. Pagination is the pattern of breaking large lists into smaller pages. The client asks for page 1, or maybe page 5, and specifies how many items they want per page. Along with the data, you send metadata: what page this is, how many total pages exist, and the total number of items.
Filtering and sorting go hand-in-hand with pagination. Let the client ask for just the products in the “electronics” category, sorted by price, lowest to highest. You do this by reading special query parameters from the URL. This puts control in the client’s hands and keeps your API fast and responsive.
// Implementing pagination, filtering, and sorting
app.get('/api/products', async (req, res) => {
// 1. Parse query parameters with sensible defaults
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const sortBy = req.query.sortBy || 'name';
const sortOrder = req.query.sortOrder === 'desc' ? -1 : 1;
const categoryFilter = req.query.category;
const minPrice = parseFloat(req.query.minPrice);
const maxPrice = parseFloat(req.query.maxPrice);
// 2. Calculate how many documents to skip
const skip = (page - 1) * limit;
// 3. Build the database query
let query = ProductModel.find({});
// Apply filters if they exist
if (categoryFilter) {
query = query.where('category').equals(categoryFilter);
}
if (!isNaN(minPrice)) {
query = query.where('price').gte(minPrice);
}
if (!isNaN(maxPrice)) {
query = query.where('price').lte(maxPrice);
}
// 4. Get the total count for metadata (apply filters here too)
const totalItems = await ProductModel.countDocuments(query.getFilter());
// 5. Apply sorting and pagination
const products = await query
.sort({ [sortBy]: sortOrder })
.skip(skip)
.limit(limit);
// 6. Calculate metadata
const totalPages = Math.ceil(totalItems / limit);
// 7. Send the response
res.json({
data: products,
pagination: {
currentPage: page,
totalPages: totalPages,
totalItems: totalItems,
itemsPerPage: limit,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
},
filters: {
category: categoryFilter,
minPrice: isNaN(minPrice) ? undefined : minPrice,
maxPrice: isNaN(maxPrice) ? undefined : maxPrice,
sortBy,
sortOrder: sortOrder === 1 ? 'asc' : 'desc'
}
});
});
Things will go wrong. A file won’t be found. A database connection will drop. A user will send malformed JSON. How your API handles these errors is critical. I aim for consistency. Every error response from my API should have a similar structure: an error code, a human-readable message, and maybe some details.
I create a set of custom error classes. A NotFoundError for missing resources. A ValidationError for bad data. A DatabaseError for when the database fails. Then, I have a single error-handling middleware at the very end of my Express app. It catches any error thrown anywhere, identifies its type, and formats a proper JSON response. This means I never have to write res.status(500).json(...) inside my route handlers.
// Centralized Error Handling
// First, define some specific error types.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // We expect this type of error
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
this.name = 'NotFoundError';
}
}
class BadRequestError extends AppError {
constructor(message = 'Bad request') {
super(message, 400);
this.name = 'BadRequestError';
}
}
// In your route, you can throw these errors naturally.
app.get('/api/orders/:orderId', verifyToken, async (req, res, next) => {
try {
const order = await OrderModel.findById(req.params.orderId);
if (!order) {
throw new NotFoundError('Order');
}
// Check if the user is allowed to see this order
if (order.userId.toString() !== req.userId) {
return res.status(403).json({ message: 'Access forbidden' });
}
res.json(order);
} catch (error) {
next(error); // Pass the error to the central handler
}
});
// The global error handling middleware - put this LAST in your app.js
app.use((error, req, res, next) => {
// Log the error for your own records
console.error('API Error:', {
name: error.name,
message: error.message,
stack: error.stack,
path: req.path,
method: req.method,
timestamp: new Date()
});
// Default values
let statusCode = error.statusCode || 500;
let message = error.message || 'An unexpected error occurred';
let details = null;
// Handle specific known error types
if (error.name === 'ValidationError') { // e.g., from Mongoose
statusCode = 400;
message = 'Data validation failed';
details = Object.values(error.errors).map(err => err.message);
}
if (error.code === 11000) { // MongoDB duplicate key
statusCode = 409;
message = 'A resource with this value already exists';
const field = Object.keys(error.keyPattern)[0];
details = `Duplicate value for field: ${field}`;
}
// Send the formatted error response
const response = {
error: {
code: statusCode,
message: message
},
timestamp: new Date().toISOString()
};
// Only include details in development or for client errors
if (details && (process.env.NODE_ENV === 'development' || statusCode < 500)) {
response.error.details = details;
}
// Don't expose internal server error details in production
if (statusCode >= 500 && process.env.NODE_ENV === 'production') {
response.error.message = 'Internal server error';
}
res.status(statusCode).json(response);
});
An API without tests is a fragile API. I write tests to make sure my endpoints behave as I expect. I test the happy path—when everything works. More importantly, I test the sad paths—when the user sends bad data, when a resource is missing, when the token is expired. I use a testing framework like Jest and a library like Supertest that can simulate HTTP requests to my running Express app.
I structure my tests to be independent. Each test starts with a clean database. I mock external services, like a payment gateway, so my tests don’t rely on the internet. This gives me confidence. When I make a change, I can run my test suite and know immediately if I broke something.
// API Testing with Jest and Supertest
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../app'); // Your Express app
const User = require('../models/User');
// Connect to a test database before all tests
beforeAll(async () => {
await mongoose.connect(process.env.TEST_DATABASE_URL);
});
// Clear the database before each test
beforeEach(async () => {
await User.deleteMany({});
});
// Close the connection after all tests
afterAll(async () => {
await mongoose.connection.close();
});
describe('User Registration API', () => {
it('should create a new user with valid data', async () => {
const userData = {
name: 'Alice Smith',
email: '[email protected]',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201); // Expecting a "Created" status
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(userData.name);
expect(response.body.email).toBe(userData.email);
// Make sure password hash is not sent back
expect(response.body).not.toHaveProperty('password');
// Verify the user was actually saved to the database
const dbUser = await User.findOne({ email: userData.email });
expect(dbUser).toBeTruthy();
expect(dbUser.name).toBe(userData.name);
});
it('should return a 400 error for invalid email', async () => {
const badData = {
name: 'Bob',
email: 'not-an-email',
password: 'password'
};
const response = await request(app)
.post('/api/users')
.send(badData)
.expect(400);
expect(response.body.error.message).toContain('validation');
});
it('should not allow duplicate emails', async () => {
const userData = {
name: 'First User',
email: '[email protected]',
password: 'password'
};
// Create the first user
await request(app).post('/api/users').send(userData);
// Try to create a second user with the same email
const response = await request(app)
.post('/api/users')
.send({ ...userData, name: 'Second User' })
.expect(409); // Conflict
expect(response.body.error.message).toContain('already exists');
});
});
describe('Protected User Profile API', () => {
let authToken;
beforeEach(async () => {
// Create a test user and get their token
const user = await User.create({
name: 'Test User',
email: '[email protected]',
passwordHash: await bcrypt.hash('password123', 10)
});
// Simulate a login to get a token
const loginRes = await request(app)
.post('/api/login')
.send({
email: '[email protected]',
password: 'password123'
});
authToken = loginRes.body.token;
});
it('should allow user to get their own profile', async () => {
const response = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.email).toBe('[email protected]');
});
it('should return 401 without a token', async () => {
await request(app)
.get('/api/users/me')
.expect(401);
});
it('should return 401 with an invalid token', async () => {
await request(app)
.get('/api/users/me')
.set('Authorization', 'Bearer fake.token.here')
.expect(401);
});
});
Finally, I want to talk about documentation. Your API is useless if people don’t know how to use it. I don’t mean a massive PDF no one reads. I mean clear, interactive documentation. I often use tools like Swagger/OpenAPI. With a few comments in my code, I can generate a web page that lists every endpoint, shows what parameters it expects, and even lets you try it out right there in the browser.
Good documentation includes examples of requests and responses. It explains error codes. It shows how to get an authentication token. When I invest time in documentation, I get fewer support questions and happier developers using my API. It’s the final, crucial pattern for a robust Node.js application.
// Using comments to generate OpenAPI/Swagger docs (with a library like 'swagger-jsdoc')
/**
* @swagger
* /api/users:
* post:
* summary: Create a new user
* tags: [Users]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - email
* - password
* properties:
* name:
* type: string
* example: John Doe
* email:
* type: string
* format: email
* example: john@example.com
* password:
* type: string
* format: password
* minLength: 8
* example: Str0ngP@ss!
* responses:
* 201:
* description: The user was successfully created.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 400:
* description: The request data was invalid.
*/
app.post('/api/users', userController.create);
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* properties:
* id:
* type: string
* example: 507f1f77bcf86cd799439011
* name:
* type: string
* example: John Doe
* email:
* type: string
* format: email
* example: john@example.com
* createdAt:
* type: string
* format: date-time
*/
These patterns form a toolkit. You won’t need every pattern for every project, but knowing them gives you options. Start simple. Use RESTful routes and some validation. Add authentication when you need it. Implement pagination when your data grows. Introduce structured error handling and tests as your project becomes more serious.
The goal is to build APIs that are not just functional, but also a pleasure to work with. For the developer on the other end, and for your future self who has to fix a bug at midnight. Clear patterns create predictable behavior. Predictable behavior builds trust. And that, in the end, is what makes an application robust.