AnyLearn
All lessons
Programmingintermediate

Building REST APIs with Express

Build a small REST API on Node 22 with Express: routes, middleware, JSON body parsing, Zod input validation, status codes, and a clean async error pipeline you won't outgrow.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson progress1 / 10

The mental model: app, router, middleware

An Express app is a thin pipeline. A request walks through an ordered list of middleware functions until one of them ends the response. Routes are middleware too โ€” they're just middleware that match a method + path.

import express from 'express';
const app = express();

app.use(express.json());          // middleware: parse JSON body
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(3000);

A Router is a sub-app you .use() mount under a prefix (app.use('/api', apiRouter)). Everything else โ€” auth, logging, error handling โ€” is some function with the signature (req, res, next) plugged into that pipeline at the right spot.

Full lesson text

All 10 steps on one page โ€” for reading, reference, and search.

Show

1. The mental model: app, router, middleware

An Express app is a thin pipeline. A request walks through an ordered list of middleware functions until one of them ends the response. Routes are middleware too โ€” they're just middleware that match a method + path.

import express from 'express';
const app = express();

app.use(express.json());          // middleware: parse JSON body
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(3000);

A Router is a sub-app you .use() mount under a prefix (app.use('/api', apiRouter)). Everything else โ€” auth, logging, error handling โ€” is some function with the signature (req, res, next) plugged into that pipeline at the right spot.

2. Request lifecycle

Each request flows through middleware in registration order. The first one to call res.send / res.json / res.end ends the response.

flowchart LR
  A["HTTP request"] --> B["express.json (body parser)"]
  B --> C["auth middleware"]
  C --> D["router match (GET /todos/:id)"]
  D --> E["handler"]
  E --> F["error middleware (4-arg)"]
  F --> G["HTTP response"]

3. Body parsing and content types

Express 5 still requires you to opt into body parsing. The two you almost always want:

app.use(express.json({ limit: '1mb' }));         // application/json
app.use(express.urlencoded({ extended: true })); // form posts

The limit matters โ€” without it, a hostile client can OOM your process with a huge payload. For file uploads, reach for multer (multipart/form-data); for raw bodies (webhook signatures), use express.raw({ type: 'application/json' }) on the specific route only and parse manually so you can verify the signature against the exact bytes you received.

4. Routing: methods, params, and query

Routes match an HTTP method and a path pattern. Path params come back on req.params, query string on req.query, body on req.body.

app.get('/todos', (req, res) => { /* req.query.status */ });
app.get('/todos/:id', (req, res) => { /* req.params.id */ });
app.post('/todos', (req, res) => { /* req.body */ });
app.patch('/todos/:id', (req, res) => {});
app.delete('/todos/:id', (req, res) => {});

Keep paths plural and nouns, keep verbs in the method. Reserve POST /things for create, PUT for full replace, PATCH for partial update, DELETE for delete. If you find yourself writing POST /things/createIfNew, you've drifted from REST into RPC.

5. Status codes that matter

Pick from a small set and be consistent:

  • 200 OK โ€” request succeeded, body returned.
  • 201 Created โ€” resource created; include the new resource or a Location header.
  • 204 No Content โ€” success, no body (good for DELETE).
  • 400 Bad Request โ€” the client sent something invalid.
  • 401 Unauthorized โ€” not authenticated.
  • 403 Forbidden โ€” authenticated but not allowed.
  • 404 Not Found โ€” resource doesn't exist.
  • 409 Conflict โ€” state collision (duplicate, version mismatch).
  • 422 Unprocessable Entity โ€” body parsed but failed validation.
  • 500 Internal Server Error โ€” your bug.

Don't return 200 with { error: '...' }. Browsers, fetch wrappers, and observability tools all key off the status code.

6. Validation with Zod

Never trust req.body. Validate at the edge with a schema and let your handler assume clean, typed data.

import { z } from 'zod';

const CreateTodo = z.object({
  title: z.string().min(1).max(120),
  dueAt: z.coerce.date().optional(),
  priority: z.enum(['low', 'med', 'high']).default('med'),
});

const validate = (schema: z.ZodTypeAny) =>
  (req: any, _res: any, next: any) => {
    const parsed = schema.safeParse(req.body);
    if (!parsed.success) return next({ status: 422, issues: parsed.error.issues });
    req.body = parsed.data;            // narrow to the parsed type
    next();
  };

Do the same for req.params and req.query when they aren't trivial.

7. The async error wrapper

Express 4 silently swallows promise rejections in handlers; Express 5 forwards them to next, but the wrapper pattern is still the cleanest. Either way, you want this helper so a thrown error always ends up in your error middleware:

type Handler = (req: any, res: any, next: any) => Promise<any>;
const ah = (fn: Handler) => (req: any, res: any, next: any) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/todos/:id', ah(async (req, res) => {
  const todo = await db.todos.find(req.params.id);
  if (!todo) { const e: any = new Error('not found'); e.status = 404; throw e; }
  res.json(todo);
}));

Now no handler has to write try/catch just to satisfy Express.

8. Error middleware: the 4-arg signature

Error-handling middleware is detected by arity: four parameters. Register it AFTER all your routes โ€” Express only invokes 4-arg middleware via next(err).

app.use((err: any, req: any, res: any, next: any) => {
  if (res.headersSent) return next(err);
  const status = err.status ?? 500;
  const payload: any = { error: err.message ?? 'internal error' };
  if (err.issues) payload.issues = err.issues;     // from Zod
  if (status >= 500) req.log?.error({ err }, 'unhandled');
  res.status(status).json(payload);
});

Log 5xx, don't leak stack traces in production, and never call next(err) after you've already started writing the response.

9. A minimal CRUD endpoint, end to end

Putting it together for POST /todos with validation and centralized error handling:

import express from 'express';
import { z } from 'zod';

const app = express();
app.use(express.json({ limit: '1mb' }));

const CreateTodo = z.object({
  title: z.string().min(1).max(120),
  priority: z.enum(['low','med','high']).default('med'),
});

const ah = (fn: any) => (req: any, res: any, next: any) =>
  Promise.resolve(fn(req,res,next)).catch(next);

const todos: any[] = [];

app.post('/todos', ah(async (req, res) => {
  const parsed = CreateTodo.safeParse(req.body);
  if (!parsed.success) return res.status(422).json({ issues: parsed.error.issues });
  const todo = { id: crypto.randomUUID(), ...parsed.data, done: false };
  todos.push(todo);
  res.status(201).location(`/todos/${todo.id}`).json(todo);
}));

app.use((err: any, _req: any, res: any, _next: any) =>
  res.status(err.status ?? 500).json({ error: err.message }));

app.listen(3000);

10. Production checklist

Before this leaves your laptop, make sure you have:

  • A request logger (pino-http) and a request-id header propagated end-to-end.
  • helmet() for sane security headers.
  • CORS configured intentionally โ€” never * for cookie-bearing APIs.
  • Rate limiting on auth endpoints (e.g., express-rate-limit).
  • compression() if you're returning large JSON.
  • A /health route with no auth and no DB calls for load balancers.
  • Graceful shutdown: catch SIGTERM, stop accepting new connections, drain in-flight requests, then process.exit(0).

None of these are exotic. Skipping them is what 'we'll add it later' looks like in postmortems.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. How does Express identify error-handling middleware?
    • By its function arity: it must take 4 arguments (err, req, res, next).
    • By a special name prefix like errorHandler.
    • By being registered with app.error() instead of app.use().
    • By returning a Promise that rejects.
  2. A handler throws an Error inside an async function. In Express 4 with no special wrapper, what typically happens?
    • The rejection is unhandled and the client hangs until timeout.
    • Express auto-forwards it to error middleware just like sync throws.
    • Node crashes the process immediately.
    • The Promise resolves with status 500.
  3. Which status code is the cleanest answer to 'body parsed fine but failed Zod validation'?
    • 422 Unprocessable Entity
    • 400 Bad Request only
    • 200 with an error field in the body
    • 500 Internal Server Error
  4. Why is express.json({ limit: '1mb' }) usually safer than a bare express.json()?
    • It caps the body size so a hostile client can't OOM the process.
    • It enables gzip decompression of request bodies.
    • It validates the JSON against a default schema.
    • It parses urlencoded bodies in addition to JSON.
  5. You're verifying a webhook signature against the raw bytes of the request body. Which is the correct setup?
    • Apply express.raw on just that route and verify the signature before any JSON parsing.
    • Use express.json globally and re-stringify req.body to recover the bytes.
    • Disable body parsing for the whole app.
    • Compute the signature on req.params instead.

Related lessons