Build a REST API with Node.js
Introduction
REST APIs are the backbone of modern web applications. They allow different software systems to communicate over the internet, enabling everything from mobile apps to microservices architectures.
In this comprehensive tutorial, you'll build a production-ready REST API from scratch using Node.js and Express. You'll learn industry best practices for API design, authentication, and error handling.
A complete REST API with:
- Full CRUD operations
- User authentication with JWT
- Input validation
- Error handling
- API documentation
- Production-ready structure
- Express.js framework
- RESTful API design principles
- MongoDB integration
- JWT authentication
- Input validation
- API testing
What is a REST API?
REST (Representational State Transfer) is an architectural style for designing networked applications.
REST Principles
- Client-Server: Separation of concerns
- Stateless: No session data stored
- Cacheable: Responses can be cached
- Uniform Interface: Consistent resource URLs
HTTP Methods
REST APIs use HTTP methods meaningfully:
- GET - Retrieve resources
- POST - Create new resources
- PUT - Update entire resource
- PATCH - Partial update
- DELETE - Remove resources
GET /api/users # Get all users
GET /api/users/123 # Get user 123
POST /api/users # Create new user
PUT /api/users/123 # Update user 123
DELETE /api/users/123 # Delete user 123
Project Overview
Our API will manage a simple resource: tasks. This demonstrates all CRUD operations.
Features
- User registration and login
- Create, read, update, delete tasks
- Mark tasks as complete
- Filter tasks by status
- Protected routes
API Endpoints
# Auth Routes
POST /api/auth/register - Register user
POST /api/auth/login - Login user
# Task Routes (Protected)
GET /api/tasks - Get all tasks
GET /api/tasks/:id - Get single task
POST /api/tasks - Create task
PUT /api/tasks/:id - Update task
DELETE /api/tasks/:id - Delete task
Prerequisites
- Node.js installed - Version 14+
- MongoDB - Local or Atlas
- Code editor - VS Code recommended
- API client - Postman or Insomnia
Project Setup
# Create project
mkdir rest-api
cd rest-api
npm init -y
# Install dependencies
npm install express mongoose dotenv cors bcryptjs jsonwebtoken express-validator
# Install dev dependencies
npm install --save-dev nodemon
Project Structure
rest-api/
├── config/
│ └── db.js
├── middleware/
│ └── auth.js
├── models/
│ ├── User.js
│ └── Task.js
├── routes/
│ ├── auth.js
│ └── tasks.js
├── .env
├── server.js
└── package.json
Server Basics
Let's set up the Express server and database connection.
// config/db.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
// server.js
require('dotenv'.config());
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
// Import routes
const authRoutes = require('./routes/auth');
const taskRoutes = require('./routes/tasks');
const app = express();
// Connect to database
connectDB();
// Middleware
app.use(express.json());
app.use(cors());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'API is running' });
});
// Error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500.json({ message: 'Something went wrong!' });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
PORT=5000
MONGODB_URI=mongodb://localhost:27017/rest-api
JWT_SECRET=your-super-secret-jwt-key
NODE_ENV=development
Data Models
Let's create the MongoDB models using Mongoose.
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please use a valid email']
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [6, 'Password must be at least 6 characters'],
select: false
}
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare password method
userSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
// models/Task.js
const mongoose = require('mongoose');
const taskSchema = mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'User'
},
title: {
type: String,
required: [true, 'Title is required'],
trim: true
},
description: {
type: String,
trim: true
},
completed: {
type: Boolean,
default: false
},
dueDate: {
type: Date
},
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium'
}
}, {
timestamps: true
});
// Index for faster queries
taskSchema.index({ user: 1, completed: 1 });
module.exports = mongoose.model('Task', taskSchema);
- Schema Types: String, Number, Boolean, Date
- Validation: required, minlength, match, enum
- References: Ref to other models
- Timestamps: Auto createdAt/updatedAt
- Indexes: For query performance
CRUD Routes
Let's create the authentication and task routes.
// routes/auth.js
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { check, validationResult } = require('express-validator');
const User = require('../models/User');
// Generate JWT
const generateToken = (id) => => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: '30d'
});
};
// @route POST /api/auth/register
// @desc Register user
// @access Public
router.post('/register', [
check('name', 'Name is required').not.isEmpty(),
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password must be 6+ characters').isLength({ min: 6 })
], async (req, res) => {
// Validate input
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, email, password } = req.body;
try {
// Check if user exists
let user = await User.findOne({ email });
if (user) {
return res.status(400).json({ message: 'User already exists' });
}
// Create user
user = new User({ name, email, password });
await user.save();
res.status(201.json({
_id: user._id,
name: user.name,
email: user.email,
token: generateToken(user._id)
});
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route POST /api/auth/login
// @desc Auth user & get token
// @access Public
router.post('/login', [
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password is required').exists()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
try {
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
res.json({
_id: user._id,
name: user.name,
email: user.email,
token: generateToken(user._id)
});
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
module.exports = router;
// routes/tasks.js
const express = require('express');
const router = express.Router();
const { check, validationResult } = require('express-validator');
const Task = require('../models/Task');
const User = require('../models/User');
// Middleware: Protect routes
const protect = async (req, res, next) => => {
let token;
if (req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
return res.status(401).json({ message: 'Not authorized' });
}
try {
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id);
next();
} catch (err) {
res.status(401).json({ message: 'Not authorized' });
}
};
// @route GET /api/tasks
// @desc Get all tasks for user
// @access Private
router.get('/', protect, async (req, res) => {
try {
const { completed, priority, search } = req.query;
// Build query
let query = { user: req.user._id };
if (completed !== undefined) {
query.completed = completed === 'true';
}
if (priority) {
query.priority = priority;
}
if (search) {
query.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
const tasks = await Task.find(query).sort({ createdAt: -1 });
res.json(tasks);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route POST /api/tasks
// @desc Create new task
// @access Private
router.post('/', [
protect,
check('title', 'Title is required').not.isEmpty()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const { title, description, completed, priority, dueDate } = req.body;
const task = new Task({
user: req.user._id,
title,
description,
completed: completed || false,
priority: priority || 'medium',
dueDate
});
await task.save();
res.status(201.json(task);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route PUT /api/tasks/:id
// @desc Update task
// @access Private
router.put('/:id', protect, async (req, res) => {
try {
let task = await Task.findById(req.params.id);
if (!task) {
return res.status(404).json({ message: 'Task not found' });
}
// Check ownership
if (task.user.toString() !== req.user._id.toString()) {
return res.status(401).json({ message: 'Not authorized' });
}
task = await Task.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
res.json(task);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route DELETE /api/tasks/:id
// @desc Delete task
// @access Private
router.delete('/:id', protect, async (req, res) => {
try {
const task = await Task.findById(req.params.id);
if (!task) {
return res.status(404.json({ message: 'Task not found' });
}
if (task.user.toString() !== req.user._id.toString()) {
return res.status(401).json({ message: 'Not authorized' });
}
await task.deleteOne();
res.json({ message: 'Task removed' });
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
module.exports = router;
Authentication
We already implemented JWT authentication in the routes. Let's create middleware for cleaner code.
// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const protect = async (req, res, next) => => {
let token;
// Check for Bearer token in header
if (
req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')
) {
token = req.headers.authorization.split(' ')[1];
}
// Check if token exists
if (!token) {
return res.status(401.json({ message: 'Not authorized, no token' });
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from token
req.user = await User.findById(decoded.id);
next();
} catch (err) {
res.status(401.json({ message: 'Not authorized, token failed' });
}
};
module.exports = { protect };
- Store tokens securely on the client
- Use HTTPS in production
- Set appropriate token expiration
- Never expose JWT_SECRET in client code
Error Handling
Let's add comprehensive error handling.
// middleware/errorMiddleware.js
// Not Found Handler
const notFound = (req, res, next) => {
const error = new Error(`Not Found - ${req.originalUrl}`);
res.status(404);
next(error);
};
// Error Handler
const errorHandler = (err, req, res, next) => {
// Log error for dev
console.error(err.stack);
// Mongoose bad ObjectId
if (err.name === 'CastError') {
return res.status(404.json({ message: 'Resource not found' });
}
// Mongoose duplicate key
if (err.code === 11000) {
return res.status(400.json({ message: 'Duplicate field value entered' });
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map((val) => val.message).join(', ');
return res.status(400.json({ message });
}
// Default error
res.status(err.statusCode || 500).json({
message: err.message || 'Server Error'
});
};
module.exports = { notFound, errorHandler };
Testing the API
# Start the server
npm run dev
# Or
node server.js
Testing with Postman
# Register a user
POST http://localhost:5000/api/auth/register
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}
# Login
POST http://localhost:5000/api/auth/login
Content-Type: application/json
# Get tasks (with token)
GET http://localhost:5000/api/tasks
Authorization: Bearer your_token_here
# Create task
POST http://localhost:5000/api/tasks
Authorization: Bearer your_token_here
Content-Type: application/json
{
"title": "Learn REST API",
"description": "Complete the tutorial",
"priority": "high"
}
- ✓ Register returns JWT token
- ✓ Login validates credentials
- ✓ Protected routes require token
- ✓ CRUD operations work
- ✓ Filtering works
- ✓ Error handling returns proper codes
Deployment
- Render - Free tier, Node.js support
- Railway - Easy deployment
- Heroku - Industry standard
- Use environment variables for secrets
- Enable HTTPS
- Set NODE_ENV=production
- Use production MongoDB (Atlas)
- Add rate limiting
Summary
Congratulations! You've built a complete REST API.
What You Built
- Express Server - With middleware and routing
- MongoDB Models - User and Task schemas
- JWT Authentication - Secure API access
- Full CRUD - Create, read, update, delete
- Input Validation - With express-validator
- Error Handling - Centralized middleware
Next Steps
- Add pagination
- Implement sorting
- Add rate limiting
- Create API documentation
- Add unit tests