Every REST API starts the same way: a few clean endpoints, sensible JSON, and a sense of pride. Then six months pass. The mobile client hardcodes URLs. The frontend team builds a state machine from the documentation (which is already outdated). Someone asks "how does the client know what actions are allowed for this resource?" and the answer is "it reads the docs and checks the user role in the JWT." Congratulations — you’ve built a perfectly brittle API.
HATEOAS (Hypermedia as the Engine of Application State) is the part of REST that almost nobody implements, and yet it’s the part that solves exactly this problem. The API response itself tells the client what it can do next. No out-of-band documentation required for navigation logic. State transitions are embedded in the data.
The theory is from Fielding’s dissertation. The practice is messier, which is why three competing formats exist: HAL, Siren, and JSON:API. They answer the same question differently, and the differences matter a lot at scale.
This article is a working comparison. We’ll take one domain — a simple order management API — and express it in all three formats, then talk about where each one breaks down in production.
The Problem With "Plain REST"
A typical response from a plain REST API looks like this:
{
"id": "ord_981",
"status": "pending",
"total": 149.99,
"customer_id": "usr_42"
}
What can the client do with this order? Cancel it? Pay it? Add items? The client has to already know the answer based on status, the user’s permissions, and the API docs. That logic lives in the client. When you change a business rule — say, orders over $100 require approval before payment — you update the backend, write a new doc section, and then wait for every client team to update their code.
With hypermedia, the response carries that logic:
{
"id": "ord_981",
"status": "pending",
"total": 149.99,
"_links": {
"self": { "href": "/orders/ord_981" },
"pay": { "href": "/orders/ord_981/pay", "method": "POST" },
"cancel": { "href": "/orders/ord_981/cancel", "method": "DELETE" }
}
}
No pay link? The client doesn’t show the "Pay Now" button. No cancel link? The button is gone. The API drives the UI state. That’s HATEOAS.
HAL: Hypertext Application Language
HAL (spec on GitHub) is the oldest and most widely adopted of the three. It’s deliberately minimal — just two reserved properties: _links and _embedded.
The Basic Shape
{
"id": "ord_981",
"status": "pending",
"total": 149.99,
"_links": {
"self": {
"href": "/orders/ord_981"
},
"customer": {
"href": "/users/usr_42"
},
"pay": {
"href": "/orders/ord_981/pay"
},
"cancel": {
"href": "/orders/ord_981/cancel"
},
"curies": [
{
"name": "orders",
"href": "https://docs.example.com/rels/{rel}",
"templated": true
}
]
},
"_embedded": {
"items": [
{
"id": "item_1",
"product": "Widget Pro",
"qty": 2,
"price": 74.99,
"_links": {
"self": { "href": "/order-items/item_1" },
"product": { "href": "/products/prod_55" }
}
}
]
}
}
_links holds navigation. _embedded holds sub-resources that are included inline to avoid extra requests — essentially sideloaded data. curies (Compact URIs) are HAL’s way of namespacing link relations using a URI template, so orders:invoice expands to the full documentation URL.
Collections in HAL
{
"_links": {
"self": { "href": "/orders?page=2&per_page=20" },
"first": { "href": "/orders?page=1&per_page=20" },
"prev": { "href": "/orders?page=1&per_page=20" },
"next": { "href": "/orders?page=3&per_page=20" },
"last": { "href": "/orders?page=12&per_page=20" }
},
"total": 232,
"page": 2,
"_embedded": {
"orders": [ ... ]
}
}
Pagination is handled by conventional link relations (first, prev, next, last). Clients that understand next can paginate without knowing the URL structure at all.
HAL Gotchas
No actions, only links. HAL links have href, templated, type, deprecation, name, profile, and title. That’s it. There’s no standard way to say "this is a POST" or "this expects a body with these fields." People abuse method as a custom property but it’s not in the spec, so tooling ignores it. If you need form-like semantics, HAL isn’t the right choice.
_embedded is advisory, not mandatory. The spec says an embedded resource may be a partial representation. So you can’t trust that _embedded.customer has all customer fields — it might be a stub. This causes subtle bugs when clients assume embedded data is complete.
Curies are widely ignored. In theory they enable semantic link namespacing. In practice, most consumers treat _links as a flat dictionary and never follow the curie templates. Nice concept, rarely useful.
When HAL Works
HAL is the right call when you want the smallest possible overhead on your JSON payloads and your API is fundamentally read-navigable (CRUD + traversal). It works well with existing tooling — Spring HATEOAS, Hyperstack, hal-forms for the form extension. If you’re building an API that’ll be consumed by developers who know what they’re doing and just need a reliable navigation layer, HAL is fast to implement and easy to understand.
Siren: Hypermedia With Actions
Siren (GitHub) takes the opposite philosophy: it tries to fully describe what a client can do, not just where it can navigate. It introduces entities, actions, and links as distinct concepts.
The Same Order in Siren
{
"class": ["order"],
"properties": {
"id": "ord_981",
"status": "pending",
"total": 149.99
},
"entities": [
{
"class": ["order-item"],
"rel": ["items"],
"href": "/order-items/item_1",
"properties": {
"product": "Widget Pro",
"qty": 2,
"price": 74.99
}
}
],
"actions": [
{
"name": "pay-order",
"title": "Pay Order",
"method": "POST",
"href": "/orders/ord_981/pay",
"type": "application/json",
"fields": [
{
"name": "payment_method",
"type": "text",
"value": "card"
},
{
"name": "card_token",
"type": "text"
}
]
},
{
"name": "cancel-order",
"title": "Cancel Order",
"method": "DELETE",
"href": "/orders/ord_981/cancel"
}
],
"links": [
{ "rel": ["self"], "href": "/orders/ord_981" },
{ "rel": ["customer"], "href": "/users/usr_42" }
]
}
Notice that pay-order action doesn’t just say "POST to this URL" — it tells you the expected fields: payment_method and card_token. A generic Siren client could render a form from this without any app-specific code. That’s the promise.
Siren Gotchas
Sub-entities by reference vs. by value. Siren distinguishes between embedded links (just a href) and embedded representations (full properties included). This is cleaner than HAL’s advisory _embedded, but it means your sub-entity shape is either fully loaded or fully lazy — no partial representations. You’ll be making more requests in read-heavy, deeply nested resource graphs.
class arrays are arbitrary strings. There’s no registry. Two teams on the same project will inevitably use ["order"] and ["Order"] and ["orders"] inconsistently. You need style conventions enforced at the framework level or in a linter.
Adoption is low. Siren has fewer libraries, less community momentum, and almost no mainstream framework support compared to HAL or JSON:API. If you adopt it, you’re likely writing your own serializer. That’s not necessarily a dealbreaker — it’s a simple spec — but it’s a real cost.
Verbose. A Siren payload for even a simple resource is significantly larger than the equivalent HAL or JSON:API response. On mobile networks or with large collections, this adds up.
When Siren Works
Siren shines when the API drives complex state machines with conditional actions and you want the client to stay thin. Think: multi-step workflows, admin panels, or any situation where "what the user can do" is genuinely complex and changes based on server-side state. If you’re building a backend for a low-code frontend tool, Siren’s action model is exactly right.
JSON:API
JSON:API (official site) is a full specification — not just a media type convention, but a complete protocol covering filtering, sorting, pagination, sparse fieldsets, and compound documents. It uses the media type application/vnd.api+json.
The Same Order in JSON:API
{
"data": {
"type": "orders",
"id": "ord_981",
"attributes": {
"status": "pending",
"total": 149.99
},
"relationships": {
"customer": {
"data": { "type": "users", "id": "usr_42" },
"links": {
"related": "/orders/ord_981/customer"
}
},
"items": {
"data": [
{ "type": "order-items", "id": "item_1" }
],
"links": {
"related": "/orders/ord_981/items"
}
}
},
"links": {
"self": "/orders/ord_981"
}
},
"included": [
{
"type": "order-items",
"id": "item_1",
"attributes": {
"product": "Widget Pro",
"qty": 2,
"price": 74.99
},
"links": {
"self": "/order-items/item_1"
}
}
]
}
The structural rules are strict: data is always the primary resource or array of resources. Every resource has type and id. Relationships link to other resources by type+id pairs, optionally including them in a top-level included array. You can request only what you need with sparse fieldsets (?fields[orders]=status,total).
JSON:API’s Killer Feature: Query Protocol
JSON:API specifies how to filter, sort, paginate, and include relationships as URL parameters:
GET /orders?filter[status]=pending&sort=-total&page[number]=2&page[size]=20&include=items
The spec doesn’t mandate how the server implements filtering — just how parameters are named. This means clients and servers can agree on a vocabulary without custom documentation for every endpoint.
Collections return pagination metadata:
{
"data": [ ... ],
"meta": {
"total": 232
},
"links": {
"self": "/orders?page[number]=2",
"first": "/orders?page[number]=1",
"prev": "/orders?page[number]=1",
"next": "/orders?page[number]=3",
"last": "/orders?page[number]=12"
}
}
JSON:API Gotchas
No standard action/mutation model. JSON:API describes reads and CRUD operations (POST to create, PATCH to update, DELETE to remove). But complex state transitions — "pay this order," "approve this refund" — don’t fit cleanly. You end up either abusing PATCH (sending { "data": { "attributes": { "status": "paid" } } } and calling it a state transition) or falling back to non-standard endpoints that break the spec’s shape.
included is a flat array, not a tree. All sideloaded resources go into one top-level included array regardless of nesting depth. For deeply related graphs this is actually efficient (no duplication), but it’s awkward to traverse in code without a dedicated client library.
type must be a string, but there’s no registry. Just like Siren’s class, your type values are whatever your team decides. "order" vs "orders" is a religious war waiting to happen. The convention is plural nouns, but enforcing it requires discipline.
Error objects are overspecified. JSON:API has a rich error format with id, status, code, title, detail, source, and meta. It’s genuinely good for APIs that need structured error codes. But it’s also a lot of ceremony if you’re returning { "error": "not found" } today.
When JSON:API Works
JSON:API is the right call when you have multiple resource types with complex relationships and you want a standard client library to handle traversal (Ember Data, @jsonapi/react-query, japser). It’s also strong when your consumers are other teams who need a queryable, self-consistent protocol. The query parameter conventions mean you can swap filtering backends without changing client code. For platform APIs with many consumers, this is genuinely valuable.
Side-by-Side Comparison
| Feature | HAL | Siren | JSON:API |
|---|---|---|---|
| Payload size | Small | Large | Medium |
| Link navigation | ✓ Strong | ✓ Strong | ✓ (relationships) |
| Action/form semantics | ✗ (unofficial ext) | ✓ First-class | ✗ (CRUD only) |
| Embedded resources | Advisory | By ref or value | Compound included |
| Query protocol | ✗ | ✗ | ✓ Standardized |
| Error format | ✗ (not specified) | ✗ | ✓ Rich |
| Ecosystem maturity | High | Low | High |
| Spec strictness | Loose | Medium | Strict |
| Content-Type | application/hal+json |
application/vnd.siren+json |
application/vnd.api+json |
Production-Ready Patterns
Always version via Content-Type, not URL paths. If you start with application/hal+json; profile="https://api.example.com/profiles/v2" from day one, you can evolve the schema without touching URLs. Clients that don’t send Accept headers get a sensible default. Don’t rely on /v1/ in the path — it creates a nightmare when you need to mix versions.
Derive links from authorization, not role checks in the client. If the pay link is absent from the response, it means either the action isn’t available or the user isn’t allowed — the client doesn’t need to know which. Never leak authorization logic into the client just because it’s convenient.
Don’t embed everything. In HAL and JSON:API, embedded/included data trades bandwidth for request count. Fine for small resources, terrible for collections of large objects. Profile your actual payloads before defaulting to ?include=everything.
Implement OPTIONS and HEAD. Hypermedia works best when clients can discover capabilities programmatically. OPTIONS /orders/ord_981 should return Allow: GET, PATCH, DELETE based on the resource’s current state. This is almost never implemented and almost always wished for.
Use link relations from IANA where they exist. Before inventing "pay" or "approve", check the IANA link relations registry. Standard relations like successor-version, predecessor-version, up, related, edit are already defined and understood by generic tooling.
Which One Should You Actually Use?
If you’re building a resource-oriented API consumed by frontend teams and you want the least possible ceremony: HAL. It’s the easiest to implement, the ecosystem is decent, and most developers can read a HAL response without consulting the spec.
If you’re building a platform API with multiple resource types, a queryable interface, and consumers who’ll use client libraries: JSON:API. The strictness pays off at scale. Ember Data and similar libraries make compound documents almost invisible to the client developer.
If you’re building a workflow-heavy API where state transitions are the primary concern and you’re okay maintaining your own serializer: Siren. It’s the most honest representation of a state machine API. Just accept that you’re going to write more code upfront.
And if none of these fit — if you have three endpoints and a team of two — just use _links informally. You don’t need a spec for that. Start with the simplest thing that tells clients where to go next, and evolve from there. Rigid adherence to a media type spec on a two-week project is the wrong kind of principle.
The real mistake isn’t choosing HAL over JSON:API. It’s building an API that only makes sense if the client already read the documentation. Hypermedia fixes that, whichever format carries it.