Express.js JSON APIs with Express

A JSON API is a web service that sends and receives data in JSON format instead of HTML. Mobile apps, React frontends, and third-party services all communicate with JSON APIs. Express makes building these APIs straightforward. This topic shows you how to design a proper JSON API with correct status codes, request parsing, and response formatting.

What a JSON API Does

Think of a JSON API as a waiter in a restaurant. The waiter does not bring you the entire kitchen — they bring you a structured order form to fill out (the request format) and return a plate of food (the structured response). The API defines exactly what you can ask for and exactly what shape the answer comes back in.

Client (browser / mobile app)
         │
         │  POST /api/tasks
         │  Content-Type: application/json
         │  Body: { "title": "Buy groceries", "done": false }
         │
         ▼
   Express JSON API
         │
         │  HTTP 201 Created
         │  Content-Type: application/json
         │  Body: { "id": 4, "title": "Buy groceries", "done": false }
         │
         ▼
Client receives structured data and updates the UI

Setting Up a JSON API Server

const express = require('express');
const app = express();

// Required to read JSON from request bodies
app.use(express.json());

app.listen(3000, () => {
  console.log('API running on http://localhost:3000');
});

Building a Tasks API

A tasks API is a great learning example because it covers all CRUD operations. Start with an in-memory array (no database yet):

let tasks = [
  { id: 1, title: 'Read documentation', done: false },
  { id: 2, title: 'Write tests', done: true },
  { id: 3, title: 'Deploy app', done: false }
];

let nextId = 4; // Auto-increment counter

GET /api/tasks — List All Tasks

app.get('/api/tasks', (req, res) => {
  res.status(200).json({
    success: true,
    count: tasks.length,
    data: tasks
  });
});

Response:

{
  "success": true,
  "count": 3,
  "data": [
    { "id": 1, "title": "Read documentation", "done": false },
    { "id": 2, "title": "Write tests", "done": true },
    { "id": 3, "title": "Deploy app", "done": false }
  ]
}

GET /api/tasks/:id — Get One Task

app.get('/api/tasks/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const task = tasks.find(t => t.id === id);

  if (!task) {
    return res.status(404).json({
      success: false,
      error: `Task with ID ${id} not found`
    });
  }

  res.status(200).json({ success: true, data: task });
});

POST /api/tasks — Create a Task

app.post('/api/tasks', (req, res) => {
  const { title } = req.body;

  if (!title || title.trim() === '') {
    return res.status(400).json({
      success: false,
      error: 'Task title is required'
    });
  }

  const newTask = {
    id: nextId++,
    title: title.trim(),
    done: false
  };

  tasks.push(newTask);

  res.status(201).json({
    success: true,
    data: newTask
  });
});

PATCH /api/tasks/:id — Update a Task

app.patch('/api/tasks/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const taskIndex = tasks.findIndex(t => t.id === id);

  if (taskIndex === -1) {
    return res.status(404).json({
      success: false,
      error: `Task with ID ${id} not found`
    });
  }

  // Merge changes into existing task
  tasks[taskIndex] = { ...tasks[taskIndex], ...req.body };

  res.status(200).json({
    success: true,
    data: tasks[taskIndex]
  });
});

DELETE /api/tasks/:id — Delete a Task

app.delete('/api/tasks/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const taskIndex = tasks.findIndex(t => t.id === id);

  if (taskIndex === -1) {
    return res.status(404).json({
      success: false,
      error: `Task with ID ${id} not found`
    });
  }

  tasks.splice(taskIndex, 1);

  res.status(200).json({
    success: true,
    message: `Task ${id} deleted`
  });
});

Consistent API Response Format

Always use the same response shape for success and error cases. Consistency lets clients write predictable code for handling your API:

┌──────────────────────────────────────────────────────────────┐
│             Consistent Response Format                       │
├──────────────────────┬───────────────────────────────────────┤
│  Success Response    │  Error Response                       │
├──────────────────────┼───────────────────────────────────────┤
│ {                    │ {                                     │
│   success: true,     │   success: false,                    │
│   data: { ... }      │   error: 'Description of error'      │
│ }                    │ }                                     │
└──────────────────────┴───────────────────────────────────────┘

Testing Your API with Different Tools

┌──────────────────────────────────────────────────────────────┐
│               API Testing Tools                              │
├───────────────┬──────────────────────────────────────────────┤
│ curl          │ Command-line HTTP requests                   │
│ Postman       │ GUI tool for building and testing requests   │
│ Insomnia      │ Alternative to Postman                       │
│ Thunder Client│ VS Code extension for API testing           │
│ fetch()       │ JavaScript built into modern browsers        │
└───────────────┴──────────────────────────────────────────────┘

Test your POST endpoint using curl in the terminal:

curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "New task from curl"}'

Adding Filtering to GET Requests

Allow clients to filter results using query parameters:

app.get('/api/tasks', (req, res) => {
  let result = [...tasks];

  // Filter by done status
  if (req.query.done !== undefined) {
    const doneFilter = req.query.done === 'true';
    result = result.filter(t => t.done === doneFilter);
  }

  // Filter by search term
  if (req.query.search) {
    const term = req.query.search.toLowerCase();
    result = result.filter(t => t.title.toLowerCase().includes(term));
  }

  res.status(200).json({
    success: true,
    count: result.length,
    data: result
  });
});

Clients can now call:

GET /api/tasks?done=false          → Only incomplete tasks
GET /api/tasks?search=deploy       → Tasks containing "deploy"
GET /api/tasks?done=true&search=test → Completed tasks with "test"

Complete API Endpoint Map

┌──────────────────────────────────────────────────────────────────┐
│                   Tasks API Endpoints                            │
├────────────┬──────────────────┬────────────────────────────────  ┤
│ Method     │ URL              │ Action                           │
├────────────┼──────────────────┼──────────────────────────────────┤
│ GET        │ /api/tasks       │ List all tasks (with filters)    │
│ GET        │ /api/tasks/:id   │ Get one task by ID              │
│ POST       │ /api/tasks       │ Create a new task               │
│ PATCH      │ /api/tasks/:id   │ Update task fields              │
│ DELETE     │ /api/tasks/:id   │ Delete a task                   │
└────────────┴──────────────────┴──────────────────────────────────┘

Handling Unknown Routes for APIs

Return JSON (not HTML) for 404 errors in an API context:

// Must be after all other routes
app.use((req, res) => {
  res.status(404).json({
    success: false,
    error: `Route ${req.method} ${req.path} not found`
  });
});

Summary

A JSON API receives requests in JSON format and sends structured JSON responses. Add express.json() middleware to read request bodies. Design each endpoint around an HTTP method and a URL that clearly names the resource. Return consistent response shapes using a success field and either a data or error field. Use correct HTTP status codes — 200 for success, 201 for creation, 400 for bad input, 404 for missing resources, and 500 for server errors. Add query string parameters to GET routes to support filtering and searching without additional endpoints.

Leave a Comment

Your email address will not be published. Required fields are marked *