Serving JSON APIs

Difficulty: Beginner

JSON (JavaScript Object Notation) is the standard data format for web APIs. When building a Node.js API server, you will send and receive JSON data in almost every request. Understanding how to properly serialize, deserialize, and transmit JSON is essential for backend development.

To send a JSON response, you need to do two things: set the Content-Type header to 'application/json' and convert your JavaScript object to a JSON string using JSON.stringify(). The Content-Type header tells the client that the response body is JSON, so it knows how to parse it. Without this header, the client may try to interpret the JSON as plain text or HTML.

Receiving JSON data from a client (e.g., in a POST request) is more involved because HTTP request bodies arrive as a stream of data chunks, not as a single string. You must listen for 'data' events to collect the chunks, then listen for the 'end' event to know when the full body has been received. Once you have the complete body string, you parse it with JSON.parse() to convert it back into a JavaScript object. This streaming approach is necessary because request bodies can be very large and Node.js needs to handle them without blocking the event loop.

Error handling is critical when parsing JSON. If the client sends malformed JSON, JSON.parse() will throw a SyntaxError. You should always wrap JSON.parse() in a try/catch block and return a 400 (Bad Request) status code if parsing fails. Similarly, you should validate that the parsed data contains the expected fields before processing it.

A well-designed JSON API follows consistent patterns: it always returns JSON (even for errors), uses appropriate HTTP status codes, includes meaningful error messages, and structures responses predictably. A common pattern is to wrap successful data in { data: ... } and errors in { error: ... } or { message: ... }. This makes the API easier for frontend developers to consume.

Code examples

Simple JSON API

const http = require('http');

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
  { id: 3, name: 'Charlie', email: 'charlie@example.com' }
];

const server = http.createServer((req, res) => {
  // Helper to send JSON response
  const sendJSON = (statusCode, data) => {
    res.writeHead(statusCode, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  if (req.method === 'GET' && req.url === '/api/users') {
    sendJSON(200, { data: users, count: users.length });
  } else if (req.method === 'GET' && req.url.startsWith('/api/users/')) {
    const id = parseInt(req.url.split('/')[3]);
    const user = users.find(u => u.id === id);

    if (user) {
      sendJSON(200, { data: user });
    } else {
      sendJSON(404, { error: 'User not found' });
    }
  } else {
    sendJSON(404, { error: 'Endpoint not found' });
  }
});

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

The sendJSON helper function standardizes response formatting. Every response is JSON, even errors. The API uses URL path segments to extract the user ID for individual lookups.

Parsing POST Request Body

const http = require('http');

let items = [];
let nextId = 1;

function parseBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk.toString(); });
    req.on('end', () => {
      try {
        resolve(JSON.parse(body));
      } catch (err) {
        reject(new Error('Invalid JSON'));
      }
    });
    req.on('error', reject);
  });
}

const server = http.createServer(async (req, res) => {
  const sendJSON = (statusCode, data) => {
    res.writeHead(statusCode, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  if (req.method === 'GET' && req.url === '/api/items') {
    sendJSON(200, { data: items });
  } else if (req.method === 'POST' && req.url === '/api/items') {
    try {
      const data = await parseBody(req);

      if (!data.name) {
        sendJSON(400, { error: 'Name is required' });
        return;
      }

      const newItem = { id: nextId++, name: data.name, createdAt: new Date().toISOString() };
      items.push(newItem);
      sendJSON(201, { data: newItem });
    } catch (err) {
      sendJSON(400, { error: err.message });
    }
  } else {
    sendJSON(404, { error: 'Not Found' });
  }
});

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

The parseBody function wraps the stream-based body reading in a Promise for clean async/await usage. It handles JSON parsing errors gracefully. The POST handler validates the data before creating a new item.

Complete CRUD API

const http = require('http');

let books = [
  { id: 1, title: '1984', author: 'George Orwell' }
];
let nextId = 2;

function parseBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', chunk => { body += chunk.toString(); });
    req.on('end', () => {
      try { resolve(body ? JSON.parse(body) : {}); }
      catch (e) { reject(new Error('Invalid JSON')); }
    });
  });
}

const server = http.createServer(async (req, res) => {
  const send = (code, data) => {
    res.writeHead(code, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
  const path = parsedUrl.pathname;
  const idMatch = path.match(/^\/api\/books\/(\d+)$/);

  // GET /api/books
  if (req.method === 'GET' && path === '/api/books') {
    return send(200, { data: books });
  }

  // GET /api/books/:id
  if (req.method === 'GET' && idMatch) {
    const book = books.find(b => b.id === parseInt(idMatch[1]));
    return book ? send(200, { data: book }) : send(404, { error: 'Book not found' });
  }

  // POST /api/books
  if (req.method === 'POST' && path === '/api/books') {
    const data = await parseBody(req);
    if (!data.title || !data.author) return send(400, { error: 'Title and author required' });
    const book = { id: nextId++, title: data.title, author: data.author };
    books.push(book);
    return send(201, { data: book });
  }

  // DELETE /api/books/:id
  if (req.method === 'DELETE' && idMatch) {
    const index = books.findIndex(b => b.id === parseInt(idMatch[1]));
    if (index === -1) return send(404, { error: 'Book not found' });
    books.splice(index, 1);
    return send(200, { message: 'Book deleted' });
  }

  send(404, { error: 'Endpoint not found' });
});

server.listen(3000, () => console.log('Books API on port 3000'));

This shows a full CRUD (Create, Read, Update, Delete) API using pure Node.js. Regular expressions extract route parameters. Each HTTP method maps to a CRUD operation: GET=Read, POST=Create, DELETE=Delete. This is the pattern that Express simplifies with app.get(), app.post(), etc.

Key points

Concepts covered

Content-Type, JSON.parse, JSON.stringify, POST Body Parsing, REST API, Request Streams