Building a CRUD API
Let’s build a complete, production-ready CRUD API for managing a todo list. This tutorial brings together everything you’ve learned about Express, REST principles, and best practices.
Project Setup
Create a new project:
mkdir todo-api
cd todo-api
npm init -y
npm install express dotenv
npm install nodemon --save-dev
package.json:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
.env:
PORT=3000
NODE_ENV=development
Project Structure
todo-api/
├── server.js # Entry point
├── config/
│ └── config.js # Configuration
├── routes/
│ └── todos.js # Todo routes
├── controllers/
│ └── todoController.js # Business logic
├── middleware/
│ └── errorHandler.js # Error handling
└── .env
Data Model
In-memory data store (would be a database in production):
data/todos.js:
let todos = [
{
id: 1,
title: 'Learn Express',
completed: false,
createdAt: new Date('2024-01-01')
},
{
id: 2,
title: 'Build REST API',
completed: false,
createdAt: new Date('2024-01-02')
}
];
let nextId = 3;
module.exports = { todos, nextId };
Configuration
config/config.js:
require('dotenv').config();
module.exports = {
port: parseInt(process.env.PORT, 10) || 3000,
env: process.env.NODE_ENV || 'development',
isDevelopment() {
return this.env === 'development';
}
};
Controllers
Business logic separated from routes:
controllers/todoController.js:
const data = require('../data/todos');
// Get all todos
exports.getAllTodos = (req, res) => {
// Support filtering
let result = data.todos;
if (req.query.completed !== undefined) {
const isCompleted = req.query.completed === 'true';
result = result.filter(todo => todo.completed === isCompleted);
}
res.json({
success: true,
count: result.length,
data: result
});
};
// Get single todo
exports.getTodo = (req, res) => {
const id = parseInt(req.params.id);
const todo = data.todos.find(t => t.id === id);
if (!todo) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}
res.json({
success: true,
data: todo
});
};
// Create todo
exports.createTodo = (req, res) => {
const { title } = req.body;
// Validation
if (!title || title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Title is required'
});
}
if (title.length > 100) {
return res.status(400).json({
success: false,
error: 'Title must be less than 100 characters'
});
}
const newTodo = {
id: data.nextId++,
title: title.trim(),
completed: false,
createdAt: new Date()
};
data.todos.push(newTodo);
res.status(201).json({
success: true,
data: newTodo
});
};
// Update todo
exports.updateTodo = (req, res) => {
const id = parseInt(req.params.id);
const { title, completed } = req.body;
const todoIndex = data.todos.findIndex(t => t.id === id);
if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}
// Validation
if (title !== undefined) {
if (typeof title !== 'string' || title.trim() === '') {
return res.status(400).json({
success: false,
error: 'Invalid title'
});
}
data.todos[todoIndex].title = title.trim();
}
if (completed !== undefined) {
if (typeof completed !== 'boolean') {
return res.status(400).json({
success: false,
error: 'Completed must be a boolean'
});
}
data.todos[todoIndex].completed = completed;
}
data.todos[todoIndex].updatedAt = new Date();
res.json({
success: true,
data: data.todos[todoIndex]
});
};
// Delete todo
exports.deleteTodo = (req, res) => {
const id = parseInt(req.params.id);
const todoIndex = data.todos.findIndex(t => t.id === id);
if (todoIndex === -1) {
return res.status(404).json({
success: false,
error: 'Todo not found'
});
}
data.todos.splice(todoIndex, 1);
res.status(204).send();
};
// Delete all completed todos
exports.deleteCompleted = (req, res) => {
const originalLength = data.todos.length;
data.todos = data.todos.filter(t => !t.completed);
const deletedCount = originalLength - data.todos.length;
res.json({
success: true,
message: `Deleted ${deletedCount} completed todos`
});
};
// Get statistics
exports.getStats = (req, res) => {
const total = data.todos.length;
const completed = data.todos.filter(t => t.completed).length;
const pending = total - completed;
res.json({
success: true,
data: {
total,
completed,
pending,
completionRate: total > 0 ? (completed / total * 100).toFixed(1) + '%' : '0%'
}
});
};
Routes
routes/todos.js:
const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');
// Base routes
router.get('/', todoController.getAllTodos);
router.post('/', todoController.createTodo);
// Statistics
router.get('/stats', todoController.getStats);
// Bulk operations
router.delete('/completed', todoController.deleteCompleted);
// Single todo operations
router.get('/:id', todoController.getTodo);
router.patch('/:id', todoController.updateTodo);
router.delete('/:id', todoController.deleteTodo);
module.exports = router;
Note: Stats and bulk operations go before :id routes to avoid conflicts.
Error Handling Middleware
middleware/errorHandler.js:
const config = require('../config/config');
function errorHandler(err, req, res, next) {
console.error('Error:', err.message);
// Default error
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
// Validation errors
if (err.name === 'ValidationError') {
statusCode = 400;
message = err.message;
}
res.status(statusCode).json({
success: false,
error: message,
// Include stack trace in development
...(config.isDevelopment() && { stack: err.stack })
});
}
module.exports = errorHandler;
Request Logging Middleware
middleware/logger.js:
function logger(req, res, next) {
const start = Date.now();
// Log when response finishes
res.on('finish', () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`
);
});
next();
}
module.exports = logger;
Main Server File
server.js:
const express = require('express');
const config = require('./config/config');
const todoRoutes = require('./routes/todos');
const errorHandler = require('./middleware/errorHandler');
const logger = require('./middleware/logger');
const app = express();
// Middleware
app.use(express.json());
app.use(logger);
// Routes
app.get('/', (req, res) => {
res.json({
message: 'Todo API',
version: '1.0.0',
endpoints: {
todos: '/api/todos',
stats: '/api/todos/stats'
}
});
});
app.use('/api/todos', todoRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: 'Route not found'
});
});
// Error handler (must be last)
app.use(errorHandler);
// Start server
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
console.log(`Environment: ${config.env}`);
});
API Endpoints
Complete API reference:
GET / → API info
GET /api/todos → Get all todos
GET /api/todos?completed=true → Filter by status
GET /api/todos/:id → Get single todo
GET /api/todos/stats → Get statistics
POST /api/todos → Create todo
PATCH /api/todos/:id → Update todo
DELETE /api/todos/:id → Delete todo
DELETE /api/todos/completed → Delete all completed
Testing the API
Create Todo
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write documentation"}'
Response:
{
"success": true,
"data": {
"id": 3,
"title": "Write documentation",
"completed": false,
"createdAt": "2024-01-15T10:30:00.000Z"
}
}
Get All Todos
curl http://localhost:3000/api/todos
{
"success": true,
"count": 3,
"data": [
{ "id": 1, "title": "Learn Express", "completed": false },
{ "id": 2, "title": "Build REST API", "completed": false },
{ "id": 3, "title": "Write documentation", "completed": false }
]
}
Get Filtered Todos
curl http://localhost:3000/api/todos?completed=true
Update Todo
curl -X PATCH http://localhost:3000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed":true}'
{
"success": true,
"data": {
"id": 1,
"title": "Learn Express",
"completed": true,
"updatedAt": "2024-01-15T10:35:00.000Z"
}
}
Get Statistics
curl http://localhost:3000/api/todos/stats
{
"success": true,
"data": {
"total": 3,
"completed": 1,
"pending": 2,
"completionRate": "33.3%"
}
}
Delete Completed Todos
curl -X DELETE http://localhost:3000/api/todos/completed
{
"success": true,
"message": "Deleted 1 completed todos"
}
Adding Validation Middleware
middleware/validation.js:
exports.validateTodo = (req, res, next) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({
success: false,
error: 'Title is required'
});
}
if (typeof title !== 'string') {
return res.status(400).json({
success: false,
error: 'Title must be a string'
});
}
if (title.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'Title cannot be empty'
});
}
if (title.length > 100) {
return res.status(400).json({
success: false,
error: 'Title must be less than 100 characters'
});
}
// Sanitize
req.body.title = title.trim();
next();
};
routes/todos.js (updated):
const { validateTodo } = require('../middleware/validation');
router.post('/', validateTodo, todoController.createTodo);
router.patch('/:id', todoController.updateTodo);
Complete Example with Frontend
public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Todo App</title>
<style>
body { font-family: Arial; max-width: 600px; margin: 50px auto; }
.todo { padding: 10px; border: 1px solid #ddd; margin: 5px 0; }
.completed { text-decoration: line-through; opacity: 0.6; }
</style>
</head>
<body>
<h1>Todo List</h1>
<form id="todoForm">
<input type="text" id="todoInput" placeholder="New todo..." required>
<button type="submit">Add</button>
</form>
<div id="stats"></div>
<div id="todoList"></div>
<script>
const API_URL = 'http://localhost:3000/api/todos';
// Load todos
async function loadTodos() {
const res = await fetch(API_URL);
const json = await res.json();
const list = document.getElementById('todoList');
list.innerHTML = json.data.map(todo => `
<div class="todo ${todo.completed ? 'completed' : ''}">
<input type="checkbox"
${todo.completed ? 'checked' : ''}
onchange="toggleTodo(${todo.id}, this.checked)">
<span>${todo.title}</span>
<button onclick="deleteTodo(${todo.id})">Delete</button>
</div>
`).join('');
loadStats();
}
// Load statistics
async function loadStats() {
const res = await fetch(`${API_URL}/stats`);
const json = await res.json();
const stats = json.data;
document.getElementById('stats').innerHTML = `
<p>Total: ${stats.total} |
Completed: ${stats.completed} |
Pending: ${stats.pending}</p>
`;
}
// Add todo
document.getElementById('todoForm').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('todoInput');
const title = input.value;
await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
input.value = '';
loadTodos();
});
// Toggle todo
async function toggleTodo(id, completed) {
await fetch(`${API_URL}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed })
});
loadTodos();
}
// Delete todo
async function deleteTodo(id) {
await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
loadTodos();
}
// Initial load
loadTodos();
</script>
</body>
</html>
Serve static file:
// server.js
app.use(express.static('public'));
Best Practices
✅ DO:
- Separate concerns (routes, controllers, middleware)
- Validate all input
- Use appropriate HTTP status codes
- Return consistent response format
- Log requests and errors
- Handle 404 and 500 errors
- Use environment variables
❌ DON’T:
- Put business logic in routes
- Return different response formats
- Expose error details in production
- Forget input validation
- Use synchronous operations
Summary
Project Structure:
server.js → Entry point
config/ → Configuration
routes/ → URL routes
controllers/ → Business logic
middleware/ → Custom middleware
data/ → Data storage
Key Components:
- Routes: Define URL patterns
- Controllers: Handle business logic
- Middleware: Process requests (validation, logging, errors)
- Error Handling: Centralized error responses
- Configuration: Environment-based settings
Next Article: Error Handling Patterns