Most APIs that call themselves "RESTful" stop at clean URLs and correct HTTP methods. The original definition of REST goes one step further: responses carry links that tell the client what it can do next, just as a web page carries links and forms. In this lesson you will learn the Richardson Maturity Model, what HATEOAS means, the common link formats, and how to add hypermedia to an Express API pragmatically.
Leonard Richardson described four levels of "REST-ness". They are a useful vocabulary for reviewing any API:
| Level | Name | What it looks like |
|---|---|---|
| 0 | The swamp of POX | One URL, one method, action named in the body: POST /api with { "action": "getOrder" } |
| 1 | Resources | Separate URLs per thing: /orders/42, /customers/7, but every call is still POST |
| 2 | HTTP verbs and status codes | GET, POST, PUT, DELETE used with their real meaning; 201, 404, 409 and friends |
| 3 | Hypermedia controls | Responses include links to related resources and available actions |
Most production APIs sit at level 2, which is a good place to be. Level 3 is what HATEOAS adds.
HATEOAS stands for Hypermedia As The Engine Of Application State. A client should not hard-code every URL and business rule; it fetches a resource, reads the links in the response, and follows them. Which links are present depends on the resource's current state.
Consider an order. Whether it can be cancelled depends on whether it has shipped. Without hypermedia, every client re-implements that rule and breaks when it changes. With hypermedia, the server includes a cancel link only while cancelling is allowed:
{
"id": "ord_42",
"status": "pending",
"total": 2499,
"_links": {
"self": { "href": "/orders/ord_42" },
"customer": { "href": "/customers/cus_7" },
"pay": { "href": "/orders/ord_42/payments", "method": "POST" },
"cancel": { "href": "/orders/ord_42", "method": "DELETE" }
}
}Once the order ships, pay and cancel disappear and a track link appears. The client renders buttons for the links it finds.
There is no single mandated format. Three conventions cover almost everything you will meet:
application/hal+json): a _links object keyed by relation name, plus _embedded for included related resources. The example above is HAL-style.application/vnd.api+json): a links object and a strict envelope of data, relationships and included.Link header (RFC 8288): links outside the body, ideal for pagination because the body stays a plain array.curl -i "https://api.example.com/orders?page=3" | grep -i '^link:'
# Link: </orders?page=4>; rel="next", </orders?page=2>; rel="prev", </orders?page=9>; rel="last"Relation names such as self, next, prev and collection are registered with IANA; invent names like cancel only when no standard one fits.
Keep link construction in one function per resource so the rules live in a single place:
function orderLinks(order) {
const base = `/orders/${order.id}`;
const links = {
self: { href: base },
customer: { href: `/customers/${order.customerId}` },
};
if (order.status === "pending") {
links.pay = { href: `${base}/payments`, method: "POST" };
links.cancel = { href: base, method: "DELETE" };
}
if (order.status === "shipped") {
links.track = { href: `/shipments/${order.shipmentId}` };
}
return links;
}
app.get("/orders/:id", (req, res) => {
const order = db.get(req.params.id);
if (!order) return res.status(404).json({ error: "Order not found" });
res.json({ ...order, _links: orderLinks(order) });
});Build absolute URLs from a configured base, never from the incoming Host header, which an attacker can spoof.
self, pagination, and state-dependent actions.At which Richardson level does an API use `GET`, `POST`, `PUT` and `DELETE` with proper status codes but return no links?
_links, JSON:API links, and the RFC 8288 Link header.self, pagination and state-dependent actions deliver most of the value.Next lesson: Testing APIs with Postman and curl — send real requests to your endpoints and inspect every header and status code.