Building RESTful APIs with Node.js and Express

Aug 17, 2024
11 min read
Nodejs JavaScript API Rest Middleware CRUD

Master REST API development with Node.js. Learn routing, middleware, validation, error handling, and best practices for building production-ready APIs.

Welcome to Part 2 of our Node.js series! In Part 1, we covered the fundamentals of Node.js. Now we'll build on that foundation to create production-ready RESTful APIs using Express.js.

This tutorial covers everything from basic routing to advanced middleware patterns. After completing this guide, continue with Part 3: Database Integration to add MySQL persistence to your API.


What You'll Learn

By the end of this tutorial, you'll be able to:

  • ✅ Build a complete RESTful API with Express.js
  • ✅ Implement all CRUD operations (Create, Read, Update, Delete)
  • ✅ Use middleware for logging, validation, and error handling
  • ✅ Structure your code with modular routing
  • ✅ Apply REST API best practices
  • ✅ Handle errors gracefully
  • ✅ Validate incoming data

Prerequisites

Before starting, make sure you have:

  • Node.js and npm installed (Part 1 covers installation)
  • Basic understanding of JavaScript and HTTP concepts
  • A code editor (VS Code recommended)
  • Postman or similar API testing tool

Understanding REST APIs

REST (Representational State Transfer) is an architectural style for building web services. A RESTful API uses HTTP methods to perform operations on resources.

HTTP Methods and CRUD Operations

HTTP Method CRUD Operation Description Example
GET Read Retrieve resource(s) GET /api/todos
POST Create Create new resource POST /api/todos
PUT Update Replace entire resource PUT /api/todos/1
PATCH Update Partially update resource PATCH /api/todos/1
DELETE Delete Remove resource DELETE /api/todos/1

RESTful URL Structure

Good REST URLs:
✅ GET    /api/todos           // Get all todos
✅ GET    /api/todos/123       // Get specific todo
✅ POST   /api/todos           // Create todo
✅ PUT    /api/todos/123       // Update todo
✅ DELETE /api/todos/123       // Delete todo

Bad URLs:
❌ GET /getTodos
❌ POST /createTodo
❌ GET /todos/delete/123

Project Setup

Step 1: Initialize Your Project

Create a new directory and initialize a Node.js project:

mkdir todo-api
cd todo-api
npm init -y

Step 2: Install Dependencies

# Production dependencies
npm install express

# Development dependencies
npm install --save-dev nodemon

Why nodemon? It automatically restarts your server when files change, improving development workflow.

Step 3: Configure package.json Scripts

Update your package.json:

{
  "name": "todo-api",
  "version": "1.0.0",
  "description": "RESTful Todo API with Express",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

Building Your First Express API

Step 1: Create a Basic Express Server

Create index.js:

const express = require("express");
const app = express();
const port = 3000;

// Middleware to parse JSON request bodies
app.use(express.json());

// Basic route
app.get("/", (req, res) => {
  res.json({
    message: "Welcome to Todo API",
    version: "1.0.0",
    endpoints: {
      todos: "/api/todos",
    },
  });
});

// Start server
app.listen(port, () => {
  console.log(`🚀 Server running on http://localhost:${port}`);
});

Run your server:

npm run dev

Visit http://localhost:3000 - you should see the welcome message!


Implementing CRUD Operations

Let's build a complete Todo API with in-memory storage (we'll add database in Part 3).

Step 2: Define Data Model and Storage

Add this after your imports in index.js:

// In-memory database (temporary storage)
let todos = [
  {
    id: 1,
    title: "Learn Node.js",
    description: "Master the fundamentals of Node.js",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-01"),
  },
  {
    id: 2,
    title: "Build REST API",
    description: "Create a production-ready API",
    completed: false,
    createdAt: new Date("2024-01-02"),
    updatedAt: new Date("2024-01-02"),
  },
];

// Helper to generate unique IDs
let nextId = 3;

Step 3: GET - Retrieve All Todos

// GET /api/todos - Get all todos with optional filtering
app.get("/api/todos", (req, res) => {
  const { completed } = req.query;

  let filteredTodos = todos;

  // Filter by completion status if provided
  if (completed !== undefined) {
    const isCompleted = completed === "true";
    filteredTodos = todos.filter((todo) => todo.completed === isCompleted);
  }

  res.json({
    success: true,
    count: filteredTodos.length,
    data: filteredTodos,
  });
});

Test it:

# Get all todos
curl http://localhost:3000/api/todos

# Get only completed todos
curl http://localhost:3000/api/todos?completed=true

Step 4: GET - Retrieve Single Todo

// GET /api/todos/:id - Get a specific todo
app.get("/api/todos/:id", (req, res) => {
  const id = parseInt(req.params.id);
  const todo = 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,
  });
});

Test it:

curl http://localhost:3000/api/todos/1

Step 5: POST - Create New Todo

// POST /api/todos - Create a new todo
app.post("/api/todos", (req, res) => {
  const { title, description } = req.body;

  // Validation
  if (!title || title.trim().length === 0) {
    return res.status(400).json({
      success: false,
      error: "Title is required",
    });
  }

  // Create new todo
  const newTodo = {
    id: nextId++,
    title: title.trim(),
    description: description?.trim() || "",
    completed: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  todos.push(newTodo);

  res.status(201).json({
    success: true,
    message: "Todo created successfully",
    data: newTodo,
  });
});

Test it:

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Write blog post", "description": "About REST APIs"}'

Step 6: PUT - Update Entire Todo

// PUT /api/todos/:id - Replace entire todo
app.put("/api/todos/:id", (req, res) => {
  const id = parseInt(req.params.id);
  const { title, description, completed } = req.body;

  const todoIndex = todos.findIndex((t) => t.id === id);

  if (todoIndex === -1) {
    return res.status(404).json({
      success: false,
      error: "Todo not found",
    });
  }

  // Validation
  if (!title || title.trim().length === 0) {
    return res.status(400).json({
      success: false,
      error: "Title is required",
    });
  }

  // Replace entire todo (keeping id and createdAt)
  const updatedTodo = {
    id,
    title: title.trim(),
    description: description?.trim() || "",
    completed: completed || false,
    createdAt: todos[todoIndex].createdAt,
    updatedAt: new Date(),
  };

  todos[todoIndex] = updatedTodo;

  res.json({
    success: true,
    message: "Todo updated successfully",
    data: updatedTodo,
  });
});

Step 7: PATCH - Partially Update Todo

// PATCH /api/todos/:id - Partially update todo
app.patch("/api/todos/:id", (req, res) => {
  const id = parseInt(req.params.id);
  const updates = req.body;

  const todoIndex = todos.findIndex((t) => t.id === id);

  if (todoIndex === -1) {
    return res.status(404).json({
      success: false,
      error: "Todo not found",
    });
  }

  // Apply partial updates
  const updatedTodo = {
    ...todos[todoIndex],
    ...updates,
    id, // Prevent ID from being changed
    createdAt: todos[todoIndex].createdAt, // Prevent createdAt from being changed
    updatedAt: new Date(),
  };

  todos[todoIndex] = updatedTodo;

  res.json({
    success: true,
    message: "Todo updated successfully",
    data: updatedTodo,
  });
});

Test it:

# Mark todo as completed
curl -X PATCH http://localhost:3000/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

Step 8: DELETE - Remove Todo

// DELETE /api/todos/:id - Delete a todo
app.delete("/api/todos/:id", (req, res) => {
  const id = parseInt(req.params.id);
  const todoIndex = todos.findIndex((t) => t.id === id);

  if (todoIndex === -1) {
    return res.status(404).json({
      success: false,
      error: "Todo not found",
    });
  }

  const deletedTodo = todos.splice(todoIndex, 1)[0];

  res.json({
    success: true,
    message: "Todo deleted successfully",
    data: deletedTodo,
  });
});

Test it:

curl -X DELETE http://localhost:3000/api/todos/1

Working with Middleware

Middleware functions have access to the request (req), response (res), and the next middleware function (next).

Custom Request Logger Middleware

// Add this before your routes
const requestLogger = (req, res, next) => {
  const timestamp = new Date().toISOString();
  const method = req.method;
  const url = req.url;

  console.log(`[${timestamp}] ${method} ${url}`);

  // Log request body for POST/PUT/PATCH
  if (["POST", "PUT", "PATCH"].includes(method) && req.body) {
    console.log("Body:", JSON.stringify(req.body));
  }

  next(); // Pass control to next middleware
};

app.use(requestLogger);

Request Timing Middleware

const requestTimer = (req, res, next) => {
  const start = Date.now();

  // Listen for response finish event
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(`⏱️  ${req.method} ${req.url} - ${duration}ms`);
  });

  next();
};

app.use(requestTimer);

Validation Middleware

// Middleware factory for validating todo creation/update
const validateTodo = (req, res, next) => {
  const { title } = req.body;
  const errors = [];

  if (!title) {
    errors.push("Title is required");
  } else if (title.trim().length === 0) {
    errors.push("Title cannot be empty");
  } else if (title.length > 200) {
    errors.push("Title must be less than 200 characters");
  }

  if (errors.length > 0) {
    return res.status(400).json({
      success: false,
      errors,
    });
  }

  next();
};

// Use in specific routes
app.post("/api/todos", validateTodo, (req, res) => {
  // ... create todo
});

Error Handling Middleware

// Add this at the END of your file, after all routes
app.use((err, req, res, next) => {
  console.error("❌ Error:", err.stack);

  res.status(err.status || 500).json({
    success: false,
    error: err.message || "Internal Server Error",
    ...(process.env.NODE_ENV === "development" && { stack: err.stack }),
  });
});

// 404 handler (also add at the end, before error handler)
app.use((req, res) => {
  res.status(404).json({
    success: false,
    error: "Route not found",
  });
});

Code Organization: Modular Routing

As your API grows, keep code organized by moving routes to separate files.

Step 1: Create Routes Directory

mkdir routes

Step 2: Create Todo Routes Module

Create routes/todos.js:

const express = require("express");
const router = express.Router();

// In-memory storage (in production, this would be a database)
let todos = [
  {
    id: 1,
    title: "Learn Node.js",
    completed: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  },
];

let nextId = 2;

// GET /api/todos
router.get("/", (req, res) => {
  const { completed } = req.query;

  let filteredTodos = todos;
  if (completed !== undefined) {
    filteredTodos = todos.filter((t) => t.completed === (completed === "true"));
  }

  res.json({
    success: true,
    count: filteredTodos.length,
    data: filteredTodos,
  });
});

// GET /api/todos/:id
router.get("/:id", (req, res) => {
  const todo = todos.find((t) => t.id === parseInt(req.params.id));

  if (!todo) {
    return res.status(404).json({ success: false, error: "Todo not found" });
  }

  res.json({ success: true, data: todo });
});

// POST /api/todos
router.post("/", (req, res) => {
  const { title, description } = req.body;

  if (!title?.trim()) {
    return res.status(400).json({ success: false, error: "Title is required" });
  }

  const newTodo = {
    id: nextId++,
    title: title.trim(),
    description: description?.trim() || "",
    completed: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  todos.push(newTodo);
  res.status(201).json({ success: true, data: newTodo });
});

// PATCH /api/todos/:id
router.patch("/:id", (req, res) => {
  const todoIndex = todos.findIndex((t) => t.id === parseInt(req.params.id));

  if (todoIndex === -1) {
    return res.status(404).json({ success: false, error: "Todo not found" });
  }

  todos[todoIndex] = {
    ...todos[todoIndex],
    ...req.body,
    id: todos[todoIndex].id,
    createdAt: todos[todoIndex].createdAt,
    updatedAt: new Date(),
  };

  res.json({ success: true, data: todos[todoIndex] });
});

// DELETE /api/todos/:id
router.delete("/:id", (req, res) => {
  const todoIndex = todos.findIndex((t) => t.id === parseInt(req.params.id));

  if (todoIndex === -1) {
    return res.status(404).json({ success: false, error: "Todo not found" });
  }

  const deleted = todos.splice(todoIndex, 1)[0];
  res.json({ success: true, data: deleted });
});

module.exports = router;

Step 3: Use the Router in Your Main File

Update index.js:

const express = require("express");
const todosRouter = require("./routes/todos");

const app = express();
const port = 3000;

// Middleware
app.use(express.json());

// Routes
app.get("/", (req, res) => {
  res.json({
    message: "Welcome to Todo API",
    endpoints: { todos: "/api/todos" },
  });
});

app.use("/api/todos", todosRouter);

// Error handling
app.use((req, res) => {
  res.status(404).json({ success: false, error: "Route not found" });
});

app.listen(port, () => {
  console.log(`🚀 Server running on http://localhost:${port}`);
});

REST API Best Practices

1. Use Proper HTTP Status Codes

// Success responses
200 OK          // Successful GET, PUT, PATCH, DELETE
201 Created     // Successful POST
204 No Content  // Successful DELETE with no body

// Client error responses
400 Bad Request      // Invalid request data
401 Unauthorized     // Authentication required
403 Forbidden        // Authenticated but not authorized
404 Not Found        // Resource doesn't exist
422 Unprocessable    // Validation errors

// Server error responses
500 Internal Server Error
503 Service Unavailable

2. Consistent Response Format

// Success response
{
  "success": true,
  "data": { ... },
  "message": "Optional success message"
}

// Error response
{
  "success": false,
  "error": "Error message",
  "errors": ["field1 error", "field2 error"] // For validation
}

3. Versioning Your API

app.use("/api/v1/todos", todosRouter);
app.use("/api/v2/todos", todosV2Router);

4. Add CORS Support

npm install cors
const cors = require("cors");

app.use(
  cors({
    origin: "http://localhost:4200", // Your frontend URL
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
    credentials: true,
  }),
);

Practice Exercises

Exercise 1: Add Search Functionality

Implement a search endpoint that finds todos by title:

GET /api/todos/search?q=keyword

Exercise 2: Add Pagination

Implement pagination for the todos list:

GET /api/todos?page=1&limit=10

Exercise 3: Add Sorting

Allow sorting by different fields:

GET /api/todos?sortBy=createdAt&order=desc

Next Steps

Congratulations! You've built a complete RESTful API with Express.js. Continue your learning:

  1. Part 3: Database Integration - Add MySQL database for persistent storage
  2. Add Authentication - Implement JWT authentication
  3. Add Testing - Write unit and integration tests with Jest
  4. Deploy Your API - Deploy to platforms like Heroku, Railway, or Vercel

Complete Code Repository

The complete code for this tutorial is available on my GitHub. Feel free to clone and experiment!


Additional Resources


Let's Connect

Building APIs with Node.js? I'd love to hear about your projects!

Happy coding! 🚀