Building a REST API sounds straightforward until you actually try to do it right. I've seen too many Express.js applications that work fine in development but fall apart in production—missing error handling, CORS issues, unorganized routes, and middleware that's applied in the wrong order. When I first started with Express, I made all these mistakes, and I learned the hard way what actually matters.
Express.js has become the de facto standard for building REST APIs in Node.js, and for good reason. It's lightweight, flexible, and has a massive ecosystem. But that flexibility can be a double-edged sword—without proper structure, your API can quickly become a mess of unorganized routes and inconsistent error handling.
📖 Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is Express.js?
Express.js is a fast, unopinionated, minimalist web framework for Node.js. It provides:
- Routing - Simple, flexible routing system
- Middleware - Powerful middleware support
- Error handling - Built-in error handling mechanisms
- Template engines - Support for various template engines
- Static files - Serve static files easily
- REST API support - Perfect for building REST APIs
Installation
First, let's install Express.js and essential dependencies:
npm install express cors dotenv
npm install --save-dev nodemon
Basic Server Setup
Creating the main server file with proper middleware configuration:
const express = require("express");
const cors = require("cors");
const config = require("./config/env.config");
const database = require("./config/database");
const routes = require("./routes");
const app = express();
// Middleware
app.use(cors({
origin: config.cors.allowedOrigins,
credentials: true
}));
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.use(express.static("uploads"));
// Development logging
if (config.isDevelopment()) {
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
}
// Routes
app.use("/", routes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
message: "Endpoint not found",
path: req.originalUrl,
});
});
// Error handler
app.use((err, req, res, next) => {
console.error("Error:", err);
if (err.name === "SequelizeValidationError") {
return res.status(400).json({
success: false,
message: "Validation error",
errors: err.errors.map((e) => ({
field: e.path,
message: e.message
})),
});
}
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal server error",
});
});
// Async server startup
async function startServer() {
try {
const dbConnected = await database.testConnection();
if (!dbConnected) {
console.error("Failed to connect to database");
process.exit(1);
}
const PORT = config.server.port;
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
console.log(`📊 Database: MySQL (Sequelize ORM)`);
console.log(`🔗 API: /api/${config.server.apiVersion}`);
});
} catch (error) {
console.error("Error starting server:", error.message);
process.exit(1);
}
}
startServer();
// Graceful shutdown
process.on("SIGINT", async () => {
console.log("Shutting down...");
await database.closeConnection();
process.exit(0);
});
module.exports = app;
Middleware Order Matters
The order of middleware is crucial:
- CORS - Must come first to handle preflight requests
- Body parsers - Parse request bodies before routes
- Static files - Serve static files
- Routes - Your API endpoints
- 404 handler - Catch undefined routes
- Error handler - Must be last to catch all errors
Environment Configuration
Setting up environment configuration:
require("dotenv").config();
module.exports = {
server: {
port: process.env.PORT || 3000,
apiVersion: process.env.API_VERSION || "v1",
nodeEnv: process.env.NODE_ENV || "development",
},
cors: {
allowedOrigins: process.env.FRONTEND_URL
? process.env.FRONTEND_URL.split(",")
: ["http://localhost:5173"],
},
isDevelopment: () => {
return process.env.NODE_ENV === "development";
},
};
Environment Variables
Create a .env file:
PORT=3000
API_VERSION=v1
NODE_ENV=development
FRONTEND_URL=http://localhost:5173,http://localhost:3000
CORS Configuration
CORS (Cross-Origin Resource Sharing) is essential for frontend integration:
const cors = require("cors");
// Basic CORS configuration
app.use(cors({
origin: process.env.FRONTEND_URL || "http://localhost:5173",
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
}));
// Advanced CORS with multiple origins
const allowedOrigins = [
"http://localhost:5173",
"http://localhost:3000",
"https://yourdomain.com",
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
}));
Organized Routing
Setting up route organization for maintainability:
const express = require("express");
const router = express.Router();
const config = require("../config/env.config");
const authRoutes = require("./auth.routes");
const productRoutes = require("./product.routes");
const categoryRoutes = require("./category.routes");
// API version prefix
const apiPrefix = `/api/${config.server.apiVersion}`;
// Health check
router.get("/health", (req, res) => {
res.json({
success: true,
message: "API is running",
timestamp: new Date().toISOString(),
});
});
// Routes
router.use(`${apiPrefix}/auth`, authRoutes);
router.use(`${apiPrefix}/products`, productRoutes);
router.use(`${apiPrefix}/categories`, categoryRoutes);
module.exports = router;
Product Routes Example
Example of organized route file:
const express = require("express");
const router = express.Router();
const productController = require("../controllers/productController");
const { verifyToken } = require("../middleware/auth.middleware");
const { storeProductFiles } = require("../utils/fileUpload");
// All routes require authentication
router.use(verifyToken);
// GET routes
router.get("/", productController.getAll);
router.get("/:id", productController.getById);
router.get("/category/:categoryId", productController.getByCategoryId);
// POST route with file upload
router.post("/", async (req, res, next) => {
await storeProductFiles(req, res, next);
productController.create(req, res);
});
// PUT route with file upload
router.put("/:id", async (req, res, next) => {
await storeProductFiles(req, res, next);
productController.update(req, res);
});
// DELETE route
router.delete("/:id", productController.delete);
module.exports = router;
Error Handling
Proper error handling is crucial for production APIs:
// Error handling middleware (must be last)
app.use((err, req, res, next) => {
console.error("Error:", err);
// Sequelize validation errors
if (err.name === "SequelizeValidationError") {
return res.status(400).json({
success: false,
message: "Validation error",
errors: err.errors.map((e) => ({
field: e.path,
message: e.message
})),
});
}
// Sequelize unique constraint errors
if (err.name === "SequelizeUniqueConstraintError") {
return res.status(409).json({
success: false,
message: "Duplicate entry",
field: err.errors[0]?.path,
});
}
// JWT errors
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
// Default error
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal server error",
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
});
});
Custom Middleware
Creating custom middleware for logging, authentication, etc.:
// Request logging middleware
const requestLogger = (req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`);
});
next();
};
// Authentication middleware
const requireAuth = async (req, res, next) => {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
// Verify token and attach user to request
const user = await verifyToken(token);
req.user = user;
next();
} catch (error) {
res.status(401).json({
success: false,
message: "Invalid token",
});
}
};
// Use middleware
app.use(requestLogger);
app.use("/api/v1/products", requireAuth);
Request Validation
Validate incoming requests before processing:
const validateRequest = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
message: "Validation error",
errors: error.details.map((detail) => ({
field: detail.path.join("."),
message: detail.message,
})),
});
}
next();
};
};
// Usage with Joi
const Joi = require("joi");
const productSchema = Joi.object({
name: Joi.string().required(),
price: Joi.number().positive().required(),
stock: Joi.number().integer().min(0).required(),
});
router.post("/", validateRequest(productSchema), productController.create);
API Response Format
Standardize your API responses:
// Success response helper
const sendSuccess = (res, data, message = "Success", statusCode = 200) => {
res.status(statusCode).json({
success: true,
message,
data,
});
};
// Error response helper
const sendError = (res, message = "Error", statusCode = 500) => {
res.status(statusCode).json({
success: false,
message,
});
};
// Usage in controllers
class ProductController {
async getAll(req, res) {
try {
const products = await productModel.getAll();
sendSuccess(res, products, "Products retrieved successfully");
} catch (error) {
sendError(res, "Error retrieving products", 500);
}
}
}
Health Check Endpoint
Add a health check endpoint for monitoring:
router.get("/health", async (req, res) => {
try {
// Check database connection
const dbStatus = await database.testConnection();
res.json({
success: true,
status: "healthy",
timestamp: new Date().toISOString(),
database: dbStatus ? "connected" : "disconnected",
uptime: process.uptime(),
});
} catch (error) {
res.status(503).json({
success: false,
status: "unhealthy",
error: error.message,
});
}
});
Production Best Practices
- Use environment variables - Never hardcode configuration
- Implement proper error handling - Catch and handle all errors gracefully
- Configure CORS properly - Specify exact origins, not wildcards
- Organize routes by feature - Keep code maintainable as it grows
- Test database connection - Before starting the server
- Implement graceful shutdown - Close database connections properly
- Set body size limits - Prevent DoS attacks from large payloads
- Add request logging - For debugging and monitoring
- Use HTTPS in production - Encrypt all traffic
- Implement rate limiting - Prevent abuse
Rate Limiting Example
const rateLimit = require("express-rate-limit");
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP, please try again later.",
});
app.use("/api/", limiter);
Project Structure
Recommended project structure:
project/
├── config/
│ ├── database.js
│ └── env.config.js
├── controllers/
│ ├── productController.js
│ └── authController.js
├── middleware/
│ ├── auth.middleware.js
│ └── errorHandler.js
├── models/
│ └── index.js
├── routes/
│ ├── index.js
│ ├── product.routes.js
│ └── auth.routes.js
├── utils/
│ └── fileUpload.js
├── .env
├── app.js
└── server.js
Resources and Further Reading
- 📚 Full Express.js REST API Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- JWT Authentication Guide - Add authentication to your Express.js API
- Sequelize ORM Setup - Database setup for your Express.js API
- Express.js Documentation - Official Express.js documentation
- Express.js Best Practices - Security best practices
- Multer File Upload Guide - Handle file uploads in Express.js
- Cloudinary Image Upload Guide - Cloud storage integration
Conclusion
Express.js provides a robust foundation for building REST APIs. With proper middleware configuration, error handling, and route organization, you can create scalable, maintainable APIs. This setup is production-ready and follows best practices for inventory management systems and other backend applications.
Key Takeaways:
- Express.js is the most popular Node.js framework for REST APIs
- Middleware order matters - CORS first, body parsers before routes, error handler last
- Organize routes by feature - Keep code maintainable as it grows
- Proper error handling - Catch and handle all errors gracefully
- CORS configuration - Essential for frontend integration
- Environment variables - Never hardcode configuration
- Health check endpoints - For monitoring and deployment
- Graceful shutdown - Close connections properly
Whether you're building a simple CRUD API or a complex system with multiple features, this guide provides the foundation you need. Express.js makes API development simple, but following best practices ensures your API is production-ready and maintainable.
What's your experience with Express.js? Share your tips and tricks in the comments below! 🚀
💡 Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on Node.js development and backend development best practices.
Top comments (0)