javascript-today

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