How to Build a REST API with Node.js and Express
Below is a comprehensive, in-depth blog-style article on How to Build a REST API with Node.js and Express, exceeding 20,000 characters. It’s structured with clear sections, detailed explanations, code examples, best practices, and next-step suggestions.
Table of Contents
- Introduction
- Why Node.js & Express?
- Prerequisites
- Project Setup
- Application Structure
- Creating Your First Endpoint
- CRUD Operations
- Create (POST)
- Read (GET)
- Update (PUT/PATCH)
- Delete (DELETE)
- Connecting to a Database
- Using MongoDB & Mongoose
- Validation & Error Handling
- Authentication & Authorization
- JWT Basics
- Protecting Routes
- Testing Your API
- Postman
- Automated Testing with Jest & Supertest
- Logging & Monitoring
- Deployment
- Heroku
- Docker
- Performance Optimization
- Security Best Practices
- Versioning Your API
- Documentation
- Conclusion & Next Steps
1. Introduction
Building a RESTful API is a foundational skill for server-side development. REST (Representational State Transfer) provides a stateless, resource-oriented architecture that APIs use to enable clients to read and manipulate data. In this step-by-step guide, you’ll learn how to:
- Set up a Node.js project with Express
- Design resourceful routes following REST conventions
- Implement CRUD operations
- Connect to MongoDB using Mongoose
- Handle validation, errors, and authentication (JWT)
- Test endpoints manually and with automated testing
- Deploy to production (Heroku and Docker)
- Apply best practices for security, performance, and versioning
By the end, you’ll have a robust boilerplate you can customize for any data model.
2. Why Node.js & Express?
- JavaScript Everywhere: Share code between frontend and backend.
- Non-blocking I/O: Ideal for handling many concurrent requests.
- Minimalist Framework: Express gives routing and middleware—you pick the rest.
- Vast Ecosystem: Access to npm for libraries, tooling, and community support.
Express is lightweight yet extensible, making it perfect for learning REST and for production-ready APIs alike.
3. Prerequisites
- Node.js & npm installed (v14+ recommended).
- Basic JavaScript (ES6+) familiarity.
- Command-line comfort (Terminal, PowerShell, etc.).
- (Optional) MongoDB running locally or a cloud instance (e.g., MongoDB Atlas).
4. Project Setup
- Create project folder
mkdir rest-api-node cd rest-api-node
- Initialize npm
npm init -y
- Install dependencies
npm install express mongoose dotenv cors
- express: Web framework
- mongoose: MongoDB ODM
- dotenv: Environment variables
- cors: Cross-origin resource sharing
- Install dev-dependencies
npm install --save-dev nodemon jest supertest
- nodemon: Auto-restart on changes
- jest, supertest: Testing
- Add scripts to package.json
"scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", "test": "jest --runInBand" }
5. Application Structure
A clean folder layout keeps code maintainable:
rest-api-node/ ├── src/ │ ├── controllers/ │ │ └── userController.js │ ├── models/ │ │ └── userModel.js │ ├── routes/ │ │ └── userRoutes.js │ ├── middleware/ │ │ ├── auth.js │ │ └── errorHandler.js │ ├── utils/ │ │ └── apiResponse.js │ ├── config/ │ │ └── db.js │ ├── index.js │ └── app.js ├── .env ├── package.json └── jest.config.js
6. Creating Your First Endpoint
Let’s start simple: a GET /api/health endpoint to check server status.
src/app.js
const express = require('express');
const cors = require('cors');
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'OK', timestamp: Date.now() });
});
module.exports = app;src/index.js
require('dotenv').config();
const mongoose = require('mongoose');
const app = require('./app');
// Connect to DB
const PORT = process.env.PORT || 5000;
mongoose.connect(process.env.MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => {
console.log('MongoDB connected');
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
})
.catch(err => console.error('DB connection error:', err));Run in development:
npm run dev
Visit http://localhost:5000/api/health — you should see:
{ "status": "OK", "timestamp": 1627890123456 }7. CRUD Operations
Let’s implement a User resource with full CRUD.
7.1 Create (POST)
Model: src/models/userModel.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true }
}, { timestamps: true });
module.exports = mongoose.model('User', userSchema);Controller: src/controllers/userController.js
const User = require('../models/userModel');
// Create new user
exports.createUser = async (req, res, next) => {
try {
const { name, email, password } = req.body;
const user = await User.create({ name, email, password });
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err);
}
};Route: src/routes/userRoutes.js
const express = require('express');
const { createUser, getAllUsers, getUserById, updateUser, deleteUser } = require('../controllers/userController');
const router = express.Router();
router.route('/')
.post(createUser)
.get(getAllUsers);
router.route('/:id')
.get(getUserById)
.put(updateUser)
.delete(deleteUser);
module.exports = router;Mounting: update src/app.js
+ const userRoutes = require('./routes/userRoutes');
// ...
app.use('/api/users', userRoutes);
+ // Global error handler
+ app.use(require('./middleware/errorHandler'));7.2 Read (GET)
Controller additions
// Get all users
exports.getAllUsers = async (req, res, next) => {
const users = await User.find();
res.status(200).json({ success: true, count: users.length, data: users });
};
// Get single user
exports.getUserById = async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.status(200).json({ success: true, data: user });
};7.3 Update (PUT/PATCH)
Controller addition
// Update user
exports.updateUser = async (req, res, next) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true
});
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.status(200).json({ success: true, data: user });
};7.4 Delete (DELETE)
Controller addition
// Delete user
exports.deleteUser = async (req, res, next) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) return res.status(404).json({ success: false, message: 'User not found' });
res.status(200).json({ success: true, data: {} });
};8. Connecting to a Database
We’re using MongoDB with Mongoose.
8.1 Using MongoDB & Mongoose
- Define .env:
MONGO_URI=mongodb://localhost:27017/rest-api-db PORT=5000 JWT_SECRET=your_jwt_secret JWT_EXPIRES_IN=1d
- src/config/db.js (optional helper):
const mongoose = require('mongoose'); const connectDB = async () => { await mongoose.connect(process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true }); console.log('MongoDB connected'); }; module.exports = connectDB;- In index.js:
const connectDB = require('./config/db'); // ... connectDB().then(() => app.listen(PORT));
9. Validation & Error Handling
9.1 Request Validation
Use a library like Joi or express-validator.
Example with express-validator:
npm install express-validator
In route:
const { body } = require('express-validator');
router.post(
'/',
[
body('name').notEmpty().withMessage('Name is required'),
body('email').isEmail().withMessage('Valid email required'),
body('password').isLength({ min: 6 }).withMessage('Password ≥ 6 chars')
],
validateRequest, // custom middleware to check validationResult
createUser
);9.2 Global Error Handler
src/middleware/errorHandler.js:
module.exports = (err, req, res, next) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({
success: false,
error: err.message || 'Server Error'
});
};10. Authentication & Authorization
10.1 JWT Basics
- Install:
npm install jsonwebtoken bcryptjs
- User model: hash password using bcrypt in a pre('save') hook.
- Auth controller (src/controllers/authController.js):
const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const User = require('../models/userModel'); // Register exports.register = async (req, res, next) => { const { name, email, password } = req.body; const salt = await bcrypt.genSalt(10); const hashed = await bcrypt.hash(password, salt); const user = await User.create({ name, email, password: hashed }); sendToken(user, 201, res); }; // Login exports.login = async (req, res, next) => { const { email, password } = req.body; const user = await User.findOne({ email }).select('+password'); if (!user || !(await bcrypt.compare(password, user.password))) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } sendToken(user, 200, res); }; const sendToken = (user, statusCode, res) => { const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN }); res.status(statusCode).json({ success: true, token }); };
10.2 Protecting Routes
src/middleware/auth.js:
const jwt = require('jsonwebtoken');
const User = require('../models/userModel');
exports.protect = async (req, res, next) => {
let token;
if (req.headers.authorization?.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) return res.status(401).json({ success: false, message: 'Not authorized' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id);
next();
} catch (err) {
res.status(401).json({ success: false, message: 'Token invalid' });
}
};Mount protected route:
router.route('/me')
.get(protect, getMe);11. Testing Your API
11.1 Postman
- Create a Collection
- Define requests for each endpoint (POST, GET, PUT, DELETE)
- Use environment variables for {{base_url}} and {{token}}
11.2 Automated Testing
jest.config.js:
module.exports = {
testEnvironment: 'node'
};Example test: tests/user.test.js
const request = require('supertest');
const app = require('../src/app');
const mongoose = require('mongoose');
beforeAll(async () => {
await mongoose.connect(process.env.MONGO_URI_TEST);
});
afterAll(async () => {
await mongoose.connection.close();
});
describe('User API', () => {
it('should create a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com', password: '123456' });
expect(res.statusCode).toEqual(201);
expect(res.body.data).toHaveProperty('_id');
});
// More tests...
});Run:
npm test
12. Logging & Monitoring
- morgan for request logging:
npm install morgan
- In app.js: app.use(require('morgan')('dev'));
- Winston for application logs.
- PM2 for process management and monitoring.
13. Deployment
13.1 Heroku
- Login: heroku login
- Create app: heroku create your-app-name
- Set config:
heroku config:set MONGO_URI=<your_mongo_uri> JWT_SECRET=<secret>
- Push: git push heroku main
- Scale: heroku ps:scale web=1
13.2 Docker
Dockerfile:
FROM node:16-alpine WORKDIR /usr/src/app COPY package*.json ./ RUN npm install --only=production COPY . . CMD ["node", "src/index.js"]
.dockerignore:
node_modules npm-debug.log
Build & run:
docker build -t rest-api-node . docker run -d -p 5000:5000 --env-file .env rest-api-node
14. Performance Optimization
- Indexing in MongoDB for frequent queries.
- Compression: npm install compression, then app.use(require('compression')());.
- Caching with Redis.
- Cluster mode (PM2) for multi-core utilization.
15. Security Best Practices
- Sanitize inputs (avoid NoSQL injection).
- Use Helmet for HTTP headers:
npm install helmet
app.use(require('helmet')());- Rate limiting (e.g., express-rate-limit).
- Enforce HTTPS in production.
- Store secrets securely (Vault, environment variables).
16. Versioning Your API
URL versioning:
/api/v1/users /api/v2/users
Maintain backward compatibility by incrementing major version for breaking changes.
17. Documentation
- Swagger / OpenAPI:
npm install swagger-ui-express yamljs
- Serve swagger.json at /api-docs.
- API Blueprint or Redoc as alternatives.
18. Conclusion & Next Steps
Congratulations! You now have a fully-featured REST API built with Node.js and Express, complete with:
- Health check endpoint
- Full CRUD on a User model
- MongoDB integration with Mongoose
- Validation, error handling, and logging
- JWT-based authentication and route protection
- Manual and automated testing
- Deployment strategies (Heroku, Docker)
- Best practices for security, performance, and versioning
- Documentation via Swagger
What to explore next
- Add role-based access control (e.g., admin vs. regular user).
- Integrate file uploads (using Multer).
- Build a frontend client (React, Vue, or Angular) consuming your API.
- Shift to a microservices architecture.
- Incorporate GraphQL alongside or in place of REST.
Feel free to fork this boilerplate, adapt it to your domain (products, posts, orders, etc.), and share your improvements on GitHub!