Build a Practical REST API with Node.js and Express
Modern web applications rely on fast, well-structured backends that expose data through a clean API. Tutorials often cover the basics, but a practical REST API requires attention to design, error handling, security, and deployment. In this guide, you’ll learn how to build a robust REST API using Node.js and Express, with optional MongoDB persistence. The goal is to provide a maintainable foundation you can extending as your project grows.
Prerequisites
- Basic knowledge of JavaScript and HTTP concepts (methods, status codes, headers).
- Node.js installed (version 14 or newer).
- MongoDB instance available for persistence (local or Atlas).
- A text editor you’re comfortable with for editing code.
Project setup
Start by creating a new project directory and initializing it as a Node.js project. Then install the core packages you’ll need: Express for routing, Mongoose for MongoDB interaction, and dotenv for environment configuration.
mkdir rest-api-demo
cd rest-api-demo
npm init -y
npm install express mongoose dotenv
Structuring the project
A clean structure helps you scale later. Consider organizing files like this:
- src/server.js — entry point that starts the Express server
- src/routes/itemRoutes.js — defines the REST endpoints for items
- src/models/item.js — Mongoose schema for the data model
- src/controllers/itemController.js — business logic for handling requests
- .env — configuration like database URL and ports
Creating a minimal Express app
Set up a simple Express application to verify your environment. This example uses a quick route to confirm the server is running.
// src/server.js
require('dotenv').config();
const express = require('express');
const app = express();
// Built-in middleware to parse JSON bodies
app.use(express.json());
const PORT = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('REST API is up and running');
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
Designing the API endpoints
A practical REST API should have a predictable set of endpoints for the core resource. For a simple inventory example, you can expose:
- GET /api/items — list items
- POST /api/items — create a new item
- GET /api/items/:id — retrieve a single item
- PUT /api/items/:id — update an item
- DELETE /api/items/:id — remove an item
These endpoints support common CRUD operations and align with standard HTTP methods. As your API grows, you can introduce pagination, filtering, and sorting for the list endpoint.
Connecting to MongoDB (optional but common)
If you choose to persist data, MongoDB is a popular pairing with Node.js and Express. Start by creating a Mongoose model for the item resource, then wire up the database connection in your server.
// src/models/item.js
const mongoose = require('mongoose');
const itemSchema = new mongoose.Schema({
name: { type: String, required: true },
quantity: { type: Number, default: 1 },
createdAt: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Item', itemSchema);
// src/server.js (continued)
const mongoose = require('mongoose');
const Item = require('./models/item');
mongoose
.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true })
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));
const router = require('./routes/itemRoutes');
app.use('/api', router);
Implementing routes and business logic
Separate routing from business logic to keep the codebase maintainable. Create route definitions and a controller that handles the actual operations. Below is a concise example.
// src/routes/itemRoutes.js
const express = require('express');
const router = express.Router();
const itemController = require('../controllers/itemController');
router.get('/items', itemController.listItems);
router.post('/items', itemController.createItem);
router.get('/items/:id', itemController.getItem);
router.put('/items/:id', itemController.updateItem);
router.delete('/items/:id', itemController.deleteItem);
module.exports = router;
// src/controllers/itemController.js
const Item = require('../models/item');
exports.listItems = async (req, res) => {
try {
const items = await Item.find({});
res.json(items);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch items' });
}
};
exports.createItem = async (req, res) => {
try {
const item = new Item(req.body);
await item.save();
res.status(201).json(item);
} catch (err) {
res.status(400).json({ error: err.message });
}
};
exports.getItem = async (req, res) => {
try {
const item = await Item.findById(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
res.json(item);
} catch (err) {
res.status(500).json({ error: 'Failed to retrieve item' });
}
};
exports.updateItem = async (req, res) => {
try {
const item = await Item.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
if (!item) return res.status(404).json({ error: 'Item not found' });
res.json(item);
} catch (err) {
res.status(400).json({ error: err.message });
}
};
exports.deleteItem = async (req, res) => {
try {
const result = await Item.findByIdAndDelete(req.params.id);
if (!result) return res.status(404).json({ error: 'Item not found' });
res.json({ message: 'Item deleted' });
} catch (err) {
res.status(500).json({ error: 'Failed to delete item' });
}
};
Handling validation, errors, and security
Robust APIs validate input, handle errors gracefully, and minimize exposure to common vulnerabilities. Here are practical steps you can adopt:
- Validate request bodies and parameters. Simple checks (presence of required fields, types) prevent bad data from entering the database.
- Return meaningful HTTP status codes: 200 for success, 201 for created, 400 for bad requests, 404 for not found, 500 for server errors.
- Use defensive programming around database calls to avoid leaking stack traces to clients.
- Configure basic security headers and enable rate limiting in production to mitigate abuse.
- Store sensitive configuration in environment variables (for example, MONGO_URI and PORT in a .env file).
For more rigorous validation, you can integrate a validation library like Joi or express-validator. This helps keep your controllers focused on business logic rather than field-level checks.
// Example: simple validation in itemController.js (before saving)
if (!req.body.name) {
return res.status(400).json({ error: 'Name is required' });
}
Testing the API
Testing is essential to ensure the API behaves as expected. You can use Postman, Insomnia, or curl for quick checks:
// Retrieve all items
curl -i http://localhost:3000/api/items
// Create a new item
curl -X POST http://localhost:3000/api/items -H "Content-Type: application/json" -d '{"name":"Widget","quantity":5}'
// Get a single item
curl -i http://localhost:3000/api/items/
Automated tests, using frameworks like Jest or Mocha, can cover common scenarios such as successful creation, validation errors, and not-found cases. Automated tests improve confidence when you refactor or add features.
Deployment considerations
When you’re ready to deploy, plan for environment-specific configuration and observability:
- Use environment variables for secrets and configuration (PORT, MONGO_URI, API_BASE_URL).
- Pin dependencies to specific versions and keep them updated to avoid security risks.
- Set up logging and error monitoring so you can quickly detect and diagnose issues in production.
- Choose a hosting option that suits your stack, such as a containerized deployment (Docker) or a platform-as-a-service that supports Node.js.
Best practices for a maintainable REST API
- Keep endpoints stable and consistent with REST conventions.
- Separate concerns: routing, controllers, and data models should have clear responsibilities.
- Document your API as you build. Simple inline comments and a lightweight reference guide help future contributors.
- Plan for versioning. If you anticipate breaking changes, prefix routes with /v1 and consider a deprecation path for older versions.
- Implement pagination and filtering for collection endpoints to handle large datasets gracefully.
Conclusion
Building a practical REST API with Node.js and Express combines thoughtful design, reliable data handling, and attention to security and deployment. By starting with a clean project structure, defining a small but expressive set of endpoints, and adding persistence with MongoDB as needed, you’ll have a scalable foundation you can extend over time. With careful testing and monitoring, your backend will remain robust as new features, clients, and performance demands emerge.