"REST APIs must be hypertext-driven. Else, they are not RESTful." — Roy Fielding, 2008. He was clarifying what he meant in his 2000 dissertation, because by then, the industry had been calling things 'REST' for eight years that he never recognized as REST.
Reading the Dissertation Honestly
Roy Fielding's 2000 PhD dissertation, Architectural Styles and the Design of Network-based Software Architectures, defined REST as an architectural style with six constraints. Five of them — client-server, statelessness, cacheability, layered system, uniform interface — are widely adopted in what people call REST APIs. The sixth one is the one most APIs skip: HATEOAS — Hypermedia As The Engine Of Application State.
HATEOAS says: a REST client should know exactly one entry-point URI for the API. Everything else — the actions you can take, the resources you can navigate to, the next page of results, the related entities — is discovered by following links the server includes in its responses. The client doesn't hardcode /users/{id}/orders; the client GETs /users/{id} and sees a link with rel="orders" telling it where to go for the orders.
The HTML Analogy
The cleanest example of HATEOAS in the wild is the HTML web. When you open a website, your browser doesn't know what URLs the site has. You see a page; the page contains links; you click one; you get a new page with more links. The application's state evolves through hypermedia. The browser is the original true REST client — generic, knowing only how to follow links and fill forms.
Fielding's claim: JSON APIs could work the same way. The response is hypermedia (links embedded in JSON); the client is generic (it walks links by relationship name); the server stays in control of URIs because the client never hardcodes them.
What a Pure HATEOAS Response Looks Like
There are several hypermedia JSON formats; the most common is HAL (Hypertext Application Language). A HAL response looks like:
{
"id": "u_42",
"name": "Pippa",
"_links": {
"self": { "href": "/users/u_42" },
"orders": { "href": "/users/u_42/orders" },
"edit": { "href": "/users/u_42", "method": "PUT" },
"delete": { "href": "/users/u_42", "method": "DELETE" }
}
}
The client reads the resource, then looks at _links to discover what it can do next. To list orders, it follows the "orders" link. To edit, it follows "edit." It never constructs URIs from string templates; it follows what the server hands it.
/users/{id}/orders to /customers/{id}/purchases, every hardcoded-URI client breaks. A HATEOAS client just follows the orders-rel link from the user resource — wherever it points. Servers get to evolve URIs freely; clients keep working.
Why It's Powerful (in Theory)
- URI evolution. Server changes URIs at will; clients don't care because they navigate by relationship names.
- Dynamic capability discovery. The server can include or omit links based on the user's permissions, the resource's state, business rules — client behavior changes without client code changes.
- Generic clients. A browser can interact with any HTML site without server-specific code. A true HATEOAS client could do the same for JSON APIs.
- Workflow encoding. The server controls the state machine. A draft article has a "publish" link; once published, it doesn't. The client reads the available links and renders the correct UI without business logic.
The Workflow Example
Imagine an article that can be drafted, reviewed, and published. The available transitions depend on the current state. In pure HATEOAS, the server expresses these transitions as links:
# Draft state
{
"id": "a_42", "status": "draft", "title": "...",
"_links": {
"self": { "href": "/articles/a_42" },
"submit-review": { "href": "/articles/a_42/review", "method": "POST" },
"delete": { "href": "/articles/a_42", "method": "DELETE" }
}
}
# After review submission — different links
{
"id": "a_42", "status": "in-review",
"_links": {
"self": { "href": "/articles/a_42" },
"approve":{ "href": "/articles/a_42/publish", "method": "POST" },
"reject": { "href": "/articles/a_42/reject", "method": "POST" }
}
}
The client just looks at what links exist and renders matching buttons. No client-side knowledge of the article state machine. Add a new state with new transitions; existing clients display the new buttons without any code change.
The Formats and Why They Matter
Three notable hypermedia JSON formats:
- HAL (
application/hal+json) — minimal, links + embedded sub-resources. The most adopted. - JSON:API (
application/vnd.api+json) — heavier, opinionated about relationships, pagination, sparse fieldsets. - Siren (
application/vnd.siren+json) — adds actions with input fields, closer to true hypermedia.
HTML itself remains the most successful hypermedia format ever shipped. Browsers are the proof of concept; no one disputes HTML+browsers work.
cwkPippa's HATEOAS Reality
frontend/src/lib/api.ts). The Location header on 201 Created is the closest thing to hypermedia; that's it. This is fine for cwkPippa because the only client is the React frontend in the same repo, deployed together. The cost of HATEOAS would buy nothing. Lesson 3.4 explains when that calculation flips.Code
// A pure HAL response — links drive everything
{
"id": "u_42",
"name": "Pippa",
"email": "pippa@example.com",
"_links": {
"self": { "href": "/users/u_42" },
"orders": { "href": "/users/u_42/orders", "title": "User's orders" },
"avatar": { "href": "/users/u_42/avatar" },
"edit": { "href": "/users/u_42", "method": "PUT" },
"delete": { "href": "/users/u_42", "method": "DELETE" }
},
"_embedded": {
"latest-order": {
"id": "o_xyz",
"_links": { "self": { "href": "/orders/o_xyz" } }
}
}
}# A HATEOAS client — never hardcodes a URI past the entry point
import httpx
class HateoasClient:
def __init__(self, entry_point: str):
self.entry = entry_point
self.client = httpx.Client()
def root(self):
return self.client.get(self.entry).json()
def follow(self, response: dict, rel: str, method: str = 'GET', **kwargs):
"""Follow a link by relationship name."""
link = response['_links'].get(rel)
if not link:
raise RuntimeError(f'no link with rel={rel}; available: {list(response["_links"].keys())}')
method = link.get('method', method)
return self.client.request(method, link['href'], **kwargs).json()
# Usage: client never knows /users/u_42/orders explicitly
client = HateoasClient('https://api.example.com/')
root = client.root()
user = client.follow(root, 'user-by-id', params={'id': 'u_42'})
orders = client.follow(user, 'orders')
for order_link in user['_links'].get('related-orders', []):
detail = client.follow({'_links': {'self': order_link}}, 'self')
# Server moves /users/u_42/orders to /customers/u_42/purchases?
# Server updates the 'orders' rel; this client keeps working.// State machine via available links — same resource, different links per state
// Draft article — only submit-review and delete are possible
{
"id": "a_42", "status": "draft",
"_links": {
"self": { "href": "/articles/a_42" },
"submit-review": { "href": "/articles/a_42/review", "method": "POST" },
"delete": { "href": "/articles/a_42", "method": "DELETE" }
}
}
// In-review article — different transitions
{
"id": "a_42", "status": "in-review",
"_links": {
"self": { "href": "/articles/a_42" },
"approve": { "href": "/articles/a_42/publish", "method": "POST" },
"reject": { "href": "/articles/a_42/reject", "method": "POST" }
}
}
// Client renders buttons matching whatever links exist.
// Add a new state? Add new links. Clients adapt with no code change.External links
Exercise
Hint
follow(response, rel) method that looks up response._links[rel].href and HTTP-fetches it. The point is to see why HATEOAS feels heavy: every navigation is a lookup-then-fetch, error handling has to account for missing rels, debugging requires inspecting links. These costs are real — and they're exactly what Lesson 3.4 weighs against the benefits.Progress
Comments 0
🔔 Reply notifications (sign in)No comments yet — be the first.