Skip to main content

📦 Handling JSON Data

JSON is the language APIs speak. When your React front-end talks to your Express server, or one microservice calls another, the message on the wire is almost always JSON. In this lesson you'll learn how Express turns that raw JSON into a JavaScript object you can use — and how to send well-shaped JSON back.

Week 7 · Day 4 (Thursday: Working with Data) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what JSON is and how it differs from a JavaScript object literal
  • Wire up express.json() so incoming JSON bodies land on req.body
  • Send responses with res.json() and the right HTTP status codes
  • Read, validate, and transform nested JSON structures safely
  • Return a consistent JSON error shape when a request is malformed or invalid
  • Set size limits and content-type checks to keep the endpoint safe

Estimated Time: 55 minutes

Practice: Build a small books API that parses, validates, and reshapes JSON.

In This Lesson

What Is JSON?

JSON (JavaScript Object Notation) is a lightweight, text-based format for exchanging structured data. It grew out of JavaScript's object syntax, but today every language — Python, Go, Java, Rust — can read and write it. That universality is exactly why it became the default format for web APIs: it's the neutral meeting point where a browser, a Node server, and a database can all agree on what the data looks like.

Think of JSON as the shipping container of the software world. A container doesn't care whether it's carrying bananas or bicycles, or whether the ship, truck, or crane was built in Korea or Germany — its standard shape lets every system in the chain handle it the same way. JSON gives your data that same standard shape.

A typical JSON document

{
  "name": "Jane Doe",
  "age": 28,
  "isActive": true,
  "skills": ["JavaScript", "Node.js", "Express"],
  "address": {
    "city": "San Francisco",
    "state": "CA",
    "zipCode": "94105"
  }
}

JSON is not quite a JavaScript object

They look almost identical, and that trips people up. JSON is a string format with stricter rules than a JS object literal:

  • Every key must be wrapped in double quotes — "name", never name.
  • Strings use double quotes only — single quotes are invalid.
  • Allowed values are strings, numbers, booleans, arrays, objects, and null — nothing else.
  • No functions, no undefined, no comments, and no trailing commas.
  • Dates are stored as strings (usually ISO 8601, like "2026-07-31T10:00:00Z").
// Two sides of the same coin:
const jsObject = { name: "Ada", tags: ["dev"] };  // a live object in memory

const jsonText = JSON.stringify(jsObject);
// '{"name":"Ada","tags":["dev"]}'  ← a string you can send over the network

const backToObject = JSON.parse(jsonText);
// { name: "Ada", tags: ["dev"] }   ← an object again, ready to use

JSON.stringify() and JSON.parse() are the bridge between the two worlds. As you'll see, Express calls these for you behind the scenes — but knowing what's happening underneath makes the whole request cycle click.

graph LR A["JS object
in memory"] -->|JSON.stringify| B["JSON text
on the wire"] B -->|JSON.parse| C["JS object
on the other side"]

Parsing JSON with express.json()

When a client sends a POST or PUT with a JSON body, Express receives it as a raw stream of bytes. On its own, Express does not parse that body — req.body starts out undefined. You opt in with a built-in middleware called express.json().

✅ Good news: no more body-parser package

Older tutorials install a separate body-parser dependency. Since Express 4.16 (2017), the JSON and URL-encoded parsers are built into Express itself as express.json() and express.urlencoded(). Reach for the built-ins.

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

// Register the JSON body parser ONCE, near the top, before your routes.
// It runs on every request and, when the Content-Type is application/json,
// reads the body, calls JSON.parse on it, and puts the result on req.body.
app.use(express.json());

app.post('/api/users', (req, res) => {
  console.log(req.body);            // e.g. { name: 'Ada', email: 'ada@x.com' }
  const { name, email } = req.body; // now it's just a plain object
  res.status(201).json({ created: true, name, email });
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));

The order matters. app.use(express.json()) must run before the route that reads req.body, because middleware executes top-to-bottom. Put it once at the top and every route below it benefits.

sequenceDiagram participant C as Client participant J as express.json() participant H as Route Handler C->>J: POST /api/users
Content-Type: application/json
{"name":"Ada"} J->>J: Read body & JSON.parse J->>H: req.body = { name: "Ada" } H->>C: 201 Created (res.json)

Configuring the parser

express.json() accepts an options object. The two you'll actually reach for are limit and type:

app.use(express.json({
  limit: '1mb',                 // reject bodies larger than 1MB (default: '100kb')
  strict: true,                 // only accept arrays/objects at the top level (default)
  type: 'application/json'      // which Content-Type to parse (default)
}));
💡 Why strict matters: With strict: true (the default), a bare "hello" or 42 as the whole body is rejected. Turning it off lets primitives through — rarely what you want for an API.

Sending JSON Responses

Just as express.json() handles incoming data, res.json() handles outgoing data. It's the mirror image: give it a JavaScript value and it sends JSON back to the client.

// A single object
app.get('/api/user', (req, res) => {
  res.json({ id: 123, name: 'Alice Smith', role: 'Developer' });
});

// An array
app.get('/api/users', (req, res) => {
  res.json([
    { id: 123, name: 'Alice Smith' },
    { id: 456, name: 'Bob Johnson' }
  ]);
});

// With an explicit status code — chain .status() before .json()
app.post('/api/login', (req, res) => {
  const authenticated = checkCredentials(req.body); // your logic
  if (authenticated) {
    res.status(200).json({ success: true, token: 'abc123' });
  } else {
    res.status(401).json({ success: false, message: 'Invalid credentials' });
  }
});

Calling res.json() does three things for you automatically:

  • Sets the Content-Type: application/json response header.
  • Runs JSON.stringify() on the value you pass.
  • Sends the response with the correct encoding, ending the request.

💡 res.json() vs res.send()

res.send() is smart: pass it an object and it actually calls res.json() under the hood. So why prefer res.json()? Intent. It documents that this endpoint returns JSON, and it forces the JSON serializer even for values res.send() would treat differently (like a bare number, which send() would read as an HTTP status-ish string). Be explicit.

⚠️ One response per request

Once you call res.json() (or send, or end), the response is finished. Calling it again throws "Cannot set headers after they are sent." Always return after sending inside an if branch: return res.status(400).json(...).

Working with Nested Data

Real payloads are rarely flat. An order arrives with a customer object, an array of line items, and a nested shipping address. Because req.body is a normal JavaScript object, you navigate it with the same destructuring and array methods you already know.

app.post('/api/orders', (req, res) => {
  // Pull the top-level pieces out of the parsed body
  const { customer, items, shipping } = req.body;

  // Reach into nested properties safely with optional chaining (?.)
  const customerName = customer?.name ?? 'Guest';
  const shippingZip  = shipping?.address?.zipCode ?? null;

  // Reduce the line-item array into a total
  const totalPrice = (items ?? []).reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  res.status(201).json({
    orderId: 'ORD-12345',
    customerName,
    totalPrice: Number(totalPrice.toFixed(2)),
    estimatedDelivery: '3-5 business days'
  });
});

📖 Guard against missing shapes

Optional chaining (customer?.name) and nullish coalescing (?? 'Guest') are your seatbelts here. If a client forgets the shipping key, shipping?.address?.zipCode quietly yields undefined instead of crashing with "Cannot read properties of undefined." Never assume the body is shaped the way you hoped.

A nested request body is transformed into a slimmer response object req.body (nested) customer: { name, email } items: [ { price, quantity }, … ] shipping: { address: { zipCode } } transform res.json (flat) orderId: "ORD-12345" customerName: "Ada" totalPrice: 75.48 estimatedDelivery: "3-5 days"
An endpoint often accepts a rich nested body and returns a slimmer, purpose-built response.

Transforming before you respond

You'll frequently reshape data on the way out — hiding internal fields, adding computed ones, or formatting values. Array.prototype.map() is the workhorse:

app.get('/api/products', (req, res) => {
  // Pretend this came from a database
  const products = [
    { id: 1, name: 'Keyboard', price: 99.99, inventory: 50, cost: 60.00 },
    { id: 2, name: 'Monitor',  price: 249.99, inventory: 0,  cost: 150.00 }
  ];

  // Strip the internal 'cost' field and add friendly computed fields
  const publicProducts = products.map((p) => ({
    id: p.id,
    name: p.name,
    price: p.price,
    inStock: p.inventory > 0,
    formattedPrice: `$${p.price.toFixed(2)}`
  }));

  res.json(publicProducts);
});

⚠️ Never leak internal fields

Whatever object you hand to res.json() goes straight to the client — password hashes, cost prices, internal SKUs and all. Always map to an explicit "public" shape rather than returning raw database rows.

Validating Incoming JSON

The parser guarantees the body is syntactically valid JSON. It says nothing about whether the data is correct for your endpoint. A client can happily send { "age": -5 } or omit the email entirely. Validation is your gate: check the data, and reject bad input with a 400 Bad Request before it touches your database.

app.post('/api/users', (req, res) => {
  const { name, email, age } = req.body;

  // 1. Required fields
  if (!name || !email) {
    return res.status(400).json({ error: 'name and email are required' });
  }

  // 2. Format check (a simple, readable email pattern)
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailPattern.test(email)) {
    return res.status(400).json({ error: 'email format is invalid' });
  }

  // 3. Optional field, but if present it must be sane
  if (age !== undefined && (typeof age !== 'number' || age < 0)) {
    return res.status(400).json({ error: 'age must be a non-negative number' });
  }

  // Passed every check — safe to proceed
  return res.status(201).json({ success: true, user: { name, email, age } });
});

Notice the pattern: each check returns immediately on failure. This "guard clause" style keeps the happy path flat and readable, and it guarantees you never send two responses.

💡 Hand-rolled checks vs. a validation library

Manual if checks are perfect for one or two fields. Once an endpoint has many rules, a schema library reads better and centralizes the logic. Popular choices:

  • Zod — TypeScript-first schema validation, very ergonomic in modern stacks
  • Joi — mature, expressive schema description language
  • express-validator — middleware built specifically for Express routes
  • Ajv — validates against the JSON Schema standard, extremely fast

You'll use one of these in the next lesson. For now, understand what validation is doing so a library never feels like magic.

Consistent JSON Errors

When something goes wrong, the client needs a predictable shape to react to. If one endpoint returns { error: "..." } and another returns { message: "...", ok: false }, whoever consumes your API has to special-case everything. Pick one error shape and use it everywhere.

First, handle the case where the JSON itself is malformed. express.json() throws a SyntaxError when the body isn't parseable — catch it with an error-handling middleware (the four-argument signature):

app.use(express.json());

// Error-handling middleware has FOUR params: (err, req, res, next).
// Register it AFTER your routes/parsers.
app.use((err, req, res, next) => {
  // Thrown by express.json() when the body is invalid JSON
  if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
    return res.status(400).json({
      success: false,
      error: { code: 'INVALID_JSON', message: 'Request body is not valid JSON' }
    });
  }
  next(err); // not our concern — pass it along
});

For your own errors, a small custom error class keeps status codes and messages together and travels cleanly to a single handler:

class ApiError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.name = 'ApiError';
  }
}

app.get('/api/users/:id', (req, res, next) => {
  try {
    const user = findUser(req.params.id);
    if (!user) {
      throw new ApiError('User not found', 404, 'USER_NOT_FOUND');
    }
    res.json(user);
  } catch (err) {
    next(err); // hand off to the central error handler
  }
});

// Central handler — every route funnels errors here
app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  const body = {
    success: false,
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: err.message || 'Internal Server Error'
    }
  };
  // Only expose stack traces while developing
  if (process.env.NODE_ENV === 'development') body.error.stack = err.stack;
  res.status(status).json(body);
});
💡 One shape to rule them all: { success: false, error: { code, message } }. A machine can branch on code; a human can read message. You'll build this out fully in the Error handling middleware lesson later this week.

Size Limits & Safety

An open JSON endpoint is an invitation. Two quick settings block the most common abuse.

1. Cap the body size

Without a limit, an attacker can send a multi-gigabyte body and exhaust your server's memory — a denial-of-service. The limit option stops the parser early:

// Reject anything over 1MB before it's fully buffered
app.use(express.json({ limit: '1mb' }));

2. Verify the Content-Type

By default express.json() only parses requests that declare Content-Type: application/json. Anything else leaves req.body empty ({}). If your route requires JSON, you can reject the wrong type explicitly with a clear 415:

app.post('/api/data', (req, res, next) => {
  if (!req.is('application/json')) {
    return res.status(415).json({
      error: { code: 'UNSUPPORTED_MEDIA_TYPE',
               message: 'Content-Type must be application/json' }
    });
  }
  next();
});

✅ Compress large responses

JSON is text, and text compresses beautifully. Add the compression middleware to gzip responses and cut bandwidth dramatically — a one-line win for any JSON-heavy API:

const compression = require('compression');
app.use(compression());

Practice & Quiz

🏋️ Exercise 1: A validated books endpoint

Goal: Write a POST /api/books handler. It should parse the JSON body, require a non-empty title and author, default genre to "Uncategorized", and respond 201 with the created book (give it an id). Reject missing fields with 400 and your standard error shape.

const express = require('express');
const app = express();
app.use(express.json());

app.post('/api/books', (req, res) => {
  // TODO: validate title & author, apply a genre default,
  //       return 201 with the new book or 400 with an error
});

app.listen(3000);
💡 Hint

Destructure { title, author, genre } from req.body. Use guard clauses: if (!title || !title.trim()) return res.status(400).json({ error: { code: 'MISSING_TITLE', message: '...' } }). For the id, Date.now().toString() is fine for practice.

✅ Solution
app.post('/api/books', (req, res) => {
  const { title, author, genre } = req.body;

  if (!title || !title.trim()) {
    return res.status(400).json({
      success: false,
      error: { code: 'MISSING_TITLE', message: 'title is required' }
    });
  }
  if (!author || !author.trim()) {
    return res.status(400).json({
      success: false,
      error: { code: 'MISSING_AUTHOR', message: 'author is required' }
    });
  }

  const book = {
    id: Date.now().toString(),
    title: title.trim(),
    author: author.trim(),
    genre: genre?.trim() || 'Uncategorized'
  };

  return res.status(201).json({ success: true, book });
});

🏋️ Exercise 2: Reshape a response

Goal: Given an array of raw user records that include a passwordHash, write a GET /api/users handler that returns each user without the hash and with a computed displayName of "First L." (first name + last initial).

✅ Solution
app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, firstName: 'Ada', lastName: 'Lovelace', passwordHash: 'xxx' },
    { id: 2, firstName: 'Grace', lastName: 'Hopper', passwordHash: 'yyy' }
  ];

  const publicUsers = users.map(({ passwordHash, ...u }) => ({
    id: u.id,
    displayName: `${u.firstName} ${u.lastName.charAt(0)}.`
  }));

  res.json(publicUsers);
});
// → [{ id: 1, displayName: "Ada L." }, { id: 2, displayName: "Grace H." }]

The { passwordHash, ...u } rest-destructuring is a clean way to drop a field: it peels off passwordHash and leaves everything else in u.

🎯 Quick Quiz

Question 1: You send a JSON POST but req.body is undefined. What's the most likely cause?

Question 2: Which status code best fits a request whose JSON is well-formed but fails your validation rules?

Question 3: What does res.json({ id: 1 }) do that res.send() of a string does not?

Best Practices & Pitfalls

✅ Do

  • Register express.json() once, near the top, before any route that reads req.body
  • Set a sensible limit (e.g. '1mb') to guard against oversized payloads
  • Validate every field you rely on and reject bad input with 400
  • Return one consistent error shape across the whole API
  • Map database rows to an explicit public shape before responding

❌ Don't

  • Install the old body-parser package — the built-ins replaced it
  • Hand raw database objects to res.json() (you'll leak internal fields)
  • Forget to return after sending a response inside an if branch
  • Trust that req.body has the shape you expect — guard with ?.
  • Leave err.stack in production error responses

⚠️ The double-response bug

// ❌ Both branches can run — the second res crashes the request
if (!name) res.status(400).json({ error: 'name required' });
res.json({ ok: true });

// ✅ return stops execution after the first response
if (!name) return res.status(400).json({ error: 'name required' });
res.json({ ok: true });

Summary

🎉 Key Takeaways

  • JSON is a strict, universal text format — quoted keys, no functions, no comments
  • express.json() parses incoming JSON bodies onto req.body (register it first)
  • res.json() sets the content type, stringifies, and sends your response
  • Navigate nested bodies with destructuring and ?., and reshape output with map()
  • Always validate input and return a single consistent error shape
  • Set a limit and verify Content-Type to keep the endpoint safe

📚 Additional Resources

🚀 What's Next?

You can now read and write JSON bodies. But bodies aren't the only way clients send data — the URL itself carries information in route params and query strings. Next up: Query parameters and body parsing, where you'll master pagination, filtering, and sorting from the URL, plus URL-encoded form data.

🎉 Great work!

JSON in, JSON out — you've just built the core skill every API endpoint relies on.