HATEOAS: Hypermedia as the Engine of Application State
What HATEOAS is, why Roy Fielding considers it essential to REST, how hypermedia links work in practice, HAL and JSON:API formats, and an honest take on when it's worth implementing.
What Is HATEOAS?
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of REST defined by Roy Fielding in his 2000 dissertation. It states that a REST client should be able to navigate the entire API using only hyperlinks embedded in server responses — without any prior knowledge of the API's URL structure.
The concept is borrowed from the web itself: when you visit a website, you follow links — you don't need to know all URLs in advance. HATEOAS applies this same principle to APIs.
// Without HATEOAS — client must hardcode URLs
const order = await fetch('/orders/98765');
// Client knows to cancel via: POST /orders/98765/cancel
// Client knows items are at: GET /orders/98765/items
// These URLs are hardcoded in client code — coupling!
// With HATEOAS — client follows links from the response
const order = await fetch('/orders/98765');
// Response includes:
{
"id": 98765,
"status": "pending",
"total": 49.99,
"_links": {
"self": { "href": "/orders/98765", "method": "GET" },
"cancel": { "href": "/orders/98765/cancel", "method": "POST" },
"items": { "href": "/orders/98765/items", "method": "GET" },
"invoice":{ "href": "/orders/98765/invoice","method": "GET" }
}
}
// Client discovers cancel URL from response — no hardcoding
Why It Matters: Roy Fielding's View
In 2008, Roy Fielding wrote a famous blog post clarifying that most APIs called "REST" are not truly RESTful because they lack HATEOAS. His core argument:
"If the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period." — Roy Fielding, 2008
The practical benefit: if clients only follow links from responses, the server can change URLs without breaking clients. Clients are decoupled from the URL structure — they only know the entry point URL (like a homepage) and follow links from there.
The practical reality: almost no production APIs implement this fully. Clients still need to know the meaning of link relations. URL changes still require client coordination. Most teams find the development overhead outweighs the coupling reduction.
HATEOAS in Practice
A HATEOAS response embeds links alongside the resource data. Each link has:
- href: The URL of the linked resource or action
- rel: The relationship type (self, next, prev, cancel, payment, etc.)
- method: The HTTP method to use (optional but useful)
- type: The media type of the target (optional)
// Order resource with HATEOAS links
GET /orders/98765
→ 200 OK
{
"id": 98765,
"status": "pending",
"customer_id": "cust_42",
"total": 149.99,
"created_at": "2026-05-01T10:00:00Z",
"_links": {
"self": {
"href": "/orders/98765",
"method": "GET"
},
"customer": {
"href": "/customers/cust_42",
"method": "GET"
},
"items": {
"href": "/orders/98765/items",
"method": "GET"
},
"cancel": {
"href": "/orders/98765/cancel",
"method": "POST",
"title": "Cancel this order"
},
"payment": {
"href": "/orders/98765/payment",
"method": "GET"
}
}
}
// Note: "cancel" link only appears when the order CAN be cancelled
// (e.g., status = "pending"). For a "shipped" order, the link is absent.
// This is the real power: the server communicates available actions
// based on current state — clients don't need to implement this logic.
That last point is key: HATEOAS links are state-driven. The server only includes links for actions that are currently valid. A client rendering a "Cancel" button just checks for the presence of a cancel link — no business logic in the client.
HAL: Hypertext Application Language
HAL is the most widely adopted media type for HATEOAS responses. It is a simple JSON extension (application/hal+json) that standardises how links and embedded resources are represented.
HAL Structure
// HAL response (application/hal+json)
{
// Regular resource properties
"id": 98765,
"status": "pending",
"total": 149.99,
// _links: map of link relation → link object
"_links": {
"self": { "href": "/orders/98765" },
"cancel": { "href": "/orders/98765/cancel" },
"curies": [{ "name": "ea", "href": "https://example.com/docs/rels/{rel}", "templated": true }]
},
// _embedded: inline related resources (avoids extra requests)
"_embedded": {
"items": [
{
"id": 1,
"product": "Widget",
"quantity": 2,
"price": 49.99,
"_links": { "self": { "href": "/order-items/1" } }
}
]
}
}
HAL Link Relations
Link relations (the keys in _links) use IANA-registered relation types where possible:
| Relation | Meaning |
|---|---|
self | The canonical URL of this resource |
next | Next page in a collection |
prev | Previous page in a collection |
first / last | First/last page of a collection |
collection | The collection this resource belongs to |
item | An item within this collection |
related | A related resource |
Custom (e.g., cancel) | Domain-specific action or resource |
JSON:API Format
JSON:API (application/vnd.api+json) is a more opinionated specification that covers not just links but also resource identification, relationships, error handling, and filtering conventions.
// JSON:API response
{
"data": {
"type": "orders",
"id": "98765",
"attributes": {
"status": "pending",
"total": 149.99
},
"relationships": {
"customer": {
"links": { "related": "/orders/98765/customer" },
"data": { "type": "customers", "id": "cust_42" }
},
"items": {
"links": { "related": "/orders/98765/items" }
}
},
"links": {
"self": "/orders/98765"
}
},
"links": {
"self": "/orders/98765"
}
}
| Aspect | HAL | JSON:API |
|---|---|---|
| Complexity | Simple — just _links | More structured, more opinionated |
| Relationships | Links only | Links + type/id references |
| Pagination | Via next/prev links | Standardised with links object |
| Errors | Not defined | Standardised errors array |
| Tooling | Moderate | Good (many JSON:API client libraries) |
| Adoption | Wide | Growing (Ruby on Rails, Drupal) |
Richardson Maturity Model
The Richardson Maturity Model (RMM) grades REST APIs from Level 0 to Level 3, where Level 3 is full HATEOAS:
| Level | Name | Description | Example |
|---|---|---|---|
| 0 | The Swamp of POX | Single endpoint, all operations via POST | SOAP, XML-RPC style |
| 1 | Resources | Individual resource URLs (/users/42) |
URLs but everything is POST |
| 2 | HTTP Verbs | Correct use of GET, POST, PUT, DELETE + status codes | Most modern REST APIs |
| 3 | Hypermedia Controls | Responses include links to available actions (HATEOAS) | PayPal, Amazon, Spring HATEOAS |
Most well-designed production APIs sit at Level 2. Level 3 adds real value in specific scenarios (see below) but is not necessary for a good API.
Node.js: Implementing HATEOAS with HAL
// npm install hal
const hal = require('hal');
app.get('/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'Not found' });
// Build HAL resource
const resource = new hal.Resource(
{ id: order.id, status: order.status, total: order.total },
`/orders/${order.id}` // self link
);
// Add links based on current state
resource.link('customer', `/customers/${order.customer_id}`);
resource.link('items', `/orders/${order.id}/items`);
// State-driven links — only add actions that are valid NOW
if (order.status === 'pending') {
resource.link('cancel', `/orders/${order.id}/cancel`);
resource.link('confirm', `/orders/${order.id}/confirm`);
}
if (order.status === 'confirmed') {
resource.link('ship', `/orders/${order.id}/ship`);
}
if (['pending','confirmed'].includes(order.status)) {
resource.link('payment', `/orders/${order.id}/payment`);
}
res.setHeader('Content-Type', 'application/hal+json');
res.json(resource.toJSON());
});
Pagination with HAL Links
app.get('/orders', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const { orders, total } = await db.orders.paginate({ page, limit });
const resource = new hal.Resource({ total, page, limit }, '/orders');
// Pagination links
resource.link('self', `/orders?page=${page}&limit=${limit}`);
resource.link('first', `/orders?page=1&limit=${limit}`);
resource.link('last', `/orders?page=${Math.ceil(total/limit)}&limit=${limit}`);
if (page > 1)
resource.link('prev', `/orders?page=${page-1}&limit=${limit}`);
if (page * limit < total)
resource.link('next', `/orders?page=${page+1}&limit=${limit}`);
// Embed order resources
orders.forEach(order => {
const item = new hal.Resource(order, `/orders/${order.id}`);
item.link('cancel', `/orders/${order.id}/cancel`);
resource.embed('orders', item);
});
res.setHeader('Content-Type', 'application/hal+json');
res.json(resource.toJSON());
});
When to Actually Use HATEOAS
Here is an honest assessment of when HATEOAS earns its implementation cost:
Good candidates for HATEOAS
- Complex workflows with state machines: Order processing, loan applications, support tickets — resources transition through states with different available actions at each state. HATEOAS elegantly communicates which actions are available right now
- Public APIs with many diverse clients: When you don't control all consumers, HATEOAS lets you evolve URLs without breaking every client
- Pagination: Even non-HATEOAS APIs benefit from next/prev link headers or _links in paginated responses — this is the one HATEOAS pattern that almost everyone should adopt
- API discoverability tools: Developer portals and API explorers can traverse a HATEOAS API automatically
Poor candidates for HATEOAS
- Simple CRUD APIs: If your API is mostly straightforward create/read/update/delete, hypermedia adds complexity without benefit
- Internal APIs with a single client: When you control both sides, the coupling HATEOAS prevents isn't a real problem
- Small teams: The discipline required to maintain state-driven links across all resources is significant
- Performance-sensitive APIs: Adding link objects to every response increases payload size and serialization time
The pragmatic middle ground
Most teams adopt selective hypermedia — not full HATEOAS, but strategic links where they add clear value:
// Selective hypermedia — pragmatic approach
{
"id": 98765,
"status": "pending",
"total": 149.99,
// Self link — always useful
"url": "/orders/98765",
// Pagination — nearly always worth it
"pagination": {
"next": "/orders?page=2",
"prev": null
},
// State-driven actions — high value for complex workflows
"actions": {
"cancel": { "url": "/orders/98765/cancel", "method": "POST" }
}
// No need to document every possible link — just the dynamic ones
}
Frequently Asked Questions
Is HATEOAS required for an API to be called "REST"?
Technically yes, according to Roy Fielding. Practically no — the industry uses "REST" to describe APIs that follow HTTP conventions with proper methods and status codes (Richardson Level 2), regardless of hypermedia. The term "REST" has evolved beyond its strict academic definition in everyday usage.
Do any major APIs implement HATEOAS?
Some do, partially. PayPal uses HATEOAS links in their Orders API. Amazon S3 returns links in some responses. Spring HATEOAS makes it straightforward for Java developers. GitHub's REST API uses Link response headers for pagination — a lightweight form of hypermedia that almost everyone should adopt.
What is the Link header, and should I use it?
The HTTP Link header (RFC 8288) is a lightweight way to provide hypermedia links without modifying the response body. It is widely used for pagination:
Link: </orders?page=2>; rel="next",
</orders?page=1>; rel="first",
</orders?page=10>; rel="last"
This is the one HATEOAS pattern that makes sense for almost every API — it is simple, well-supported, and doesn't bloat response bodies.
Related Topics
REST API Design Guide
Complete guide to URLs, methods, status codes, versioning, and design principles.
Best Practices
REST API design patterns, naming conventions, and architectural best practices.
Pagination
Cursor-based and offset pagination — includes Link header patterns.
HTTP Headers
The Link header — HATEOAS-lite for pagination and resource relations.
Interview Questions
HATEOAS is a common REST interview topic — prepare with expert Q&A.
OpenAPI / Swagger
Document your API — including hypermedia links — with OpenAPI 3.1.