REST API Design Made Simple with Express.js


Introduction
Building maintainable REST APIs is crucial for any backend application's long-term success. A well-structured Express.js API not only makes your code easier to understand and modify but also ensures consistency, scalability, and developer productivity. This guide will walk you through creating clean, professional APIs using Express.js with proper separation of concerns and industry best practices.
Why Project Structure Matters
A clean project structure is the foundation of maintainable code. When your API grows from handling a few routes to dozens or hundreds, proper organization becomes essential for:
Developer onboarding: New team members can quickly understand the codebase
Debugging: Issues are easier to locate and fix
Testing: Well-separated concerns make unit testing straightforward
Scalability: Adding new features doesn't require restructuring existing code
Project Structure
Here's a recommended folder structure for a scalable Express.js API:
project-root/
├── src/
│ ├── controllers/
│ │ └── userController.js
│ ├── routes/
│ │ └── userRoutes.js
│ ├── middleware/
│ │ ├── errorHandler.js
│ │ └── validation.js
│ ├── models/
│ │ └── User.js
│ └── app.js
├── package.json
└── server.js
This structure follows the separation of concerns principle:
Controllers: Handle business logic and data processing
Routes: Define API endpoints and link them to controllers
Middleware: Handle cross-cutting concerns like authentication, logging, and error handling
Models: Define data structures and database interactions
CRUD Operations with Users Resource
Let's implement a complete CRUD API for a users resource. Here's how HTTP methods map to operations:
HTTP Method | Route | Operation | Description |
GET | /users | Read All | Retrieve list of all users |
POST | /users | Create | Add a new user |
GET | /users/:id | Read One | Retrieve a specific user |
PUT | /users/:id | Update | Modify an existing user |
DELETE | /users/:id | Delete | Remove a user |
API Request Flow
Client Request → Route Handler → Controller → Business Logic → Response
Setting Up the Main Application
server.js
const app = require("./src/app");
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
src/app.js
const express = require("express");
const userRoutes = require("./routes/userRoutes");
const errorHandler = require("./middleware/errorHandler");
const app = express();
// Built-in middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API routes
app.use("/api/v1/users", userRoutes);
// Error handling middleware (must be last)
app.use(errorHandler);
module.exports = app;
User Controller
src/controllers/userController.js
// In a real application, this would connect to a database
let users = [
{ id: 1, name: "John Doe", email: "john@example.com", age: 30 },
{ id: 2, name: "Jane Smith", email: "jane@example.com", age: 25 },
];
let nextId = 3;
const userController = {
// GET /users - Get all users
getAllUsers: (req, res, next) => {
try {
res.status(200).json({
message: "Users retrieved successfully",
data: users,
count: users.length,
});
} catch (error) {
next(error);
}
},
// GET /users/:id - Get user by ID
getUserById: (req, res, next) => {
try {
const { id } = req.params;
const user = users.find((u) => u.id === parseInt(id));
if (!user) {
return res.status(404).json({
message: "User not found",
});
}
res.status(200).json({
message: "User retrieved successfully",
data: user,
});
} catch (error) {
next(error);
}
},
// POST /users - Create new user
createUser: (req, res, next) => {
try {
const { name, email, age } = req.body;
// Basic validation
if (!name || !email) {
return res.status(400).json({
message: "Name and email are required",
});
}
// Check if email already exists
const existingUser = users.find((u) => u.email === email);
if (existingUser) {
return res.status(409).json({
message: "User with this email already exists",
});
}
const newUser = {
id: nextId++,
name,
email,
age: age || null,
};
users.push(newUser);
res.status(201).json({
message: "User created successfully",
data: newUser,
});
} catch (error) {
next(error);
}
},
// PUT /users/:id - Update user
updateUser: (req, res, next) => {
try {
const { id } = req.params;
const { name, email, age } = req.body;
const userIndex = users.findIndex((u) => u.id === parseInt(id));
if (userIndex === -1) {
return res.status(404).json({
message: "User not found",
});
}
// Update user data
if (name) users[userIndex].name = name;
if (email) users[userIndex].email = email;
if (age !== undefined) users[userIndex].age = age;
res.status(200).json({
message: "User updated successfully",
data: users[userIndex],
});
} catch (error) {
next(error);
}
},
// DELETE /users/:id - Delete user
deleteUser: (req, res, next) => {
try {
const { id } = req.params;
const userIndex = users.findIndex((u) => u.id === parseInt(id));
if (userIndex === -1) {
return res.status(404).json({
message: "User not found",
});
}
const deletedUser = users.splice(userIndex, 1)[0];
res.status(200).json({
message: "User deleted successfully",
data: deletedUser,
});
} catch (error) {
next(error);
}
},
};
module.exports = userController;
User Routes
src/routes/userRoutes.js
const express = require("express");
const userController = require("../controllers/userController");
const router = express.Router();
// Define routes and link to controller methods
router.get("/", userController.getAllUsers);
router.get("/:id", userController.getUserById);
router.post("/", userController.createUser);
router.put("/:id", userController.updateUser);
router.delete("/:id", userController.deleteUser);
module.exports = router;
Error Handling Middleware
Proper error handling is crucial for a professional API. Express.js error handling middleware catches and processes errors consistently.
src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error("Error:", err);
// Default error response
let statusCode = 500;
let message = "Internal Server Error";
// Handle specific error types
if (err.name === "ValidationError") {
statusCode = 400;
message = "Validation Error";
} else if (err.name === "CastError") {
statusCode = 400;
message = "Invalid ID format";
} else if (err.code === 11000) {
statusCode = 409;
message = "Duplicate resource";
}
// Send error response
res.status(statusCode).json({
message,
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
});
};
module.exports = errorHandler;
Response Format Standards
Consistency in API responses makes your API easier to consume. Here are the standard response formats used in this guide:
Success Responses
// Single resource
{
"message": "User retrieved successfully",
"data": { "id": 1, "name": "John Doe", "email": "john@example.com" }
}
// Multiple resources
{
"message": "Users retrieved successfully",
"data": [...],
"count": 10
}
Error Responses
// Client error (4xx)
{
"message": "User not found"
}
// Server error (5xx)
{
"message": "Internal Server Error"
}
HTTP Status Codes
Using proper HTTP status codes makes your API intuitive:
200 OK: Successful GET, PUT, DELETE
201 Created: Successful POST
400 Bad Request: Invalid request data
404 Not Found: Resource doesn't exist
409 Conflict: Resource already exists
500 Internal Server Error: Server-side errors
Best Practices for Scalable APIs
1. API Versioning
Always version your API to maintain backward compatibility:
// Version in URL
app.use("/api/v1/users", userRoutes);
app.use("/api/v2/users", userRoutesV2);
// Version in headers (alternative)
app.use("/api/users", versionMiddleware, userRoutes);
2. Input Validation Middleware
src/middleware/validation.js
const validateUser = (req, res, next) => {
const { name, email } = req.body;
if (!name || typeof name !== "string") {
return res.status(400).json({
message: "Name is required and must be a string",
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email || !emailRegex.test(email)) {
return res.status(400).json({
message: "Valid email is required",
});
}
next();
};
module.exports = { validateUser };
3. Environment Configuration
Use environment variables for configuration:
// .env file
PORT=3000
NODE_ENV=development
DB_CONNECTION_STRING=mongodb://localhost:27017/myapp
// In your app
const port = process.env.PORT || 3000;
const dbUrl = process.env.DB_CONNECTION_STRING;
4. Logging
Implement proper logging for debugging and monitoring:
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
5. Security Middleware
Add security middleware for production:
const helmet = require("helmet");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
app.use(helmet()); // Security headers
app.use(cors()); // CORS configuration
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
})
);
Testing Your API
Here are some example requests you can test with tools like Postman or curl:
# Get all users
GET http://localhost:3000/api/v1/users
# Create a new user
POST http://localhost:3000/api/v1/users
Content-Type: application/json
{
"name": "Alice Johnson",
"email": "alice@example.com",
"age": 28
}
# Get user by ID
GET http://localhost:3000/api/v1/users/1
# Update user
PUT http://localhost:3000/api/v1/users/1
Content-Type: application/json
{
"name": "John Updated",
"age": 31
}
# Delete user
DELETE http://localhost:3000/api/v1/users/1
Conclusion
Building clean, maintainable REST APIs with Express.js requires attention to structure, consistency, and best practices. The key principles covered in this guide include:
Separation of concerns through proper project structure
Consistent response formats for predictable API behavior
Proper error handling for robust applications
HTTP status codes for clear communication
Modular design for scalability and maintainability
By following these patterns, you'll create APIs that are not only functional but also professional, scalable, and enjoyable to work with. Remember that good API design is an investment in your application's future—it pays dividends in reduced bugs, easier maintenance, and faster feature development.
Start with these fundamentals, and gradually add more sophisticated features like authentication, rate limiting, caching, and database integration as your application grows. The solid foundation you've built will make these additions much simpler to implement.
Subscribe to my newsletter
Read articles from Jatin Verma directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
