HATEOAS in 2026: Is Hypermedia API Design Dead?
HATEOAS in 2026: Is Hypermedia API Design Dead?
HATEOAS (Hypermedia as the Engine of Application State) is the most debated REST constraint. In theory, API responses include links that tell clients what actions are possible next. In practice, almost nobody implements it. Let's be honest about why.
TL;DR
- HATEOAS is technically required by Roy Fielding's REST definition, but almost no "REST API" actually implements it
- The case for it is real: state-machine resources (orders, subscriptions) benefit from runtime action discovery
- The case against is stronger for most teams: TypeScript + OpenAPI code generation solves discoverability statically without runtime overhead
- The
available_actionspattern — returning which actions are currently valid without full URL links — captures 80% of the benefit at 20% of the cost - Use partial HATEOAS for pagination links and state-machine transitions; skip full hypermedia navigation for standard CRUD APIs
What HATEOAS Actually Means
A HATEOAS-compliant response includes links to related resources and available actions:
{
"data": {
"id": "order_123",
"status": "pending",
"total": 4999
},
"links": {
"self": "/api/orders/order_123",
"cancel": "/api/orders/order_123/cancel",
"payment": "/api/orders/order_123/pay",
"customer": "/api/customers/cust_456"
}
}
The client doesn't need to hardcode URLs. It discovers available actions from the response. If the order is already paid, the payment link disappears. If it can't be cancelled, the cancel link disappears.
Why Most APIs Ignore HATEOAS
1. Clients Hardcode Anyway
In practice, frontend developers build components that call specific endpoints. They don't write generic link-following code. The UI needs to know what /api/orders/order_123/cancel does regardless of whether it discovers the URL from a link or constructs it.
2. Type Safety > Discoverability
TypeScript, code generation from OpenAPI specs, and type-safe API clients (tRPC, orval, openapi-typescript) provide compile-time safety. Hardcoded, typed API calls are safer than dynamic link-following.
3. Bandwidth Overhead
Links add bytes to every response. In a mobile app making hundreds of API calls, including navigation links the client never uses wastes bandwidth.
4. Complexity Without Clear ROI
Implementing HATEOAS requires building a link generation system, maintaining link relationships, and handling link-driven state machines. Most teams can't justify this overhead for APIs consumed by their own frontends.
When HATEOAS Actually Helps
1. Long-Lived API Clients
If API clients are deployed and can't be easily updated (IoT devices, native mobile apps with slow update cycles), HATEOAS allows the server to change URL structures without breaking clients.
2. Complex State Machines
When resources have complex state transitions (order → paid → shipped → delivered → returned), links communicate which transitions are available. This prevents clients from showing a "Cancel" button on an already-shipped order.
3. API Marketplaces and Aggregators
Generic API clients (like Postman, API explorers) benefit from discoverability. HATEOAS lets these tools navigate an API without prior knowledge.
4. Multi-Service Architectures
When an API aggregates multiple services, links can point to different service URLs. The client follows links without knowing which service handles what.
Practical Alternatives
1. OpenAPI Specification
Document your API with OpenAPI/Swagger. Clients generate typed code from the spec. Changes are communicated through spec updates, not runtime links.
2. Envelope with Pagination Links
The most common "HATEOAS-lite" pattern — include next and previous links in paginated responses:
{
"data": [...],
"links": {
"next": "/api/users?cursor=abc123",
"prev": "/api/users?cursor=xyz789"
}
}
This is genuinely useful without full HATEOAS.
3. Actions Array
Instead of links to URLs, include an array of available actions:
{
"data": { "id": "order_123", "status": "pending" },
"available_actions": ["cancel", "pay", "update"]
}
The client knows which buttons to show without constructing URLs from links.
4. GraphQL Introspection
GraphQL's schema introspection serves the same discoverability purpose as HATEOAS — clients can explore available types, fields, and operations at runtime.
The Roy Fielding Argument
Roy Fielding's 2000 dissertation defined REST with six constraints. HATEOAS — "Hypermedia as the Engine of Application State" — is one of them, and Fielding considers it non-negotiable. In a 2008 blog post, he explicitly wrote:
"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."
By this definition, virtually no production API is actually RESTful. GitHub's API, Stripe's API, Twitter's API — none of them satisfy Fielding's original definition.
The Richardson Maturity Model
Leonard Richardson's maturity model gives a useful framework for this spectrum:
- Level 0: Single URI, single method (RPC-style, like SOAP)
- Level 1: Multiple URIs, single method (resources without HTTP semantics)
- Level 2: Multiple URIs, correct HTTP methods and status codes (what most people call REST)
- Level 3: Hypermedia controls (true REST per Fielding's definition)
Most production APIs operate at Level 2. The industry quietly agreed that Level 3 adds more complexity than value for typical use cases. Fielding remains frustrated by this.
The practical takeaway: when someone says their API is "RESTful," they mean Level 2. When Fielding says an API is RESTful, he means Level 3. These are different things, and acknowledging that gap helps teams make clearer architectural decisions rather than defending a label.
Real-World HATEOAS Implementations
A small number of APIs have committed to real hypermedia. What do they actually do?
HAL (Hypertext Application Language)
HAL is the most widely-adopted hypermedia format. It uses _links for related resources and _embedded for including related resources inline:
{
"id": "order_123",
"status": "pending",
"total": 4999,
"_links": {
"self": { "href": "/orders/order_123" },
"cancel": { "href": "/orders/order_123/cancel" },
"customer": { "href": "/customers/cust_456" }
},
"_embedded": {
"items": [
{
"id": "item_1",
"quantity": 2,
"_links": {
"product": { "href": "/products/prod_789" }
}
}
]
}
}
HAL is minimal and readable. Its weakness: no way to describe which HTTP method to use for each link, or what request body to provide. This is why full HATEOAS is hard — links alone aren't enough.
JSON:API with Links
JSON:API includes a links section and relationships for related resources. It also standardizes pagination links, which is genuinely useful:
{
"data": { "type": "orders", "id": "123", "attributes": { "status": "pending" } },
"links": {
"self": "/orders/123",
"next": "/orders?page=2"
},
"relationships": {
"customer": {
"links": { "related": "/customers/456" }
}
}
}
What GitHub and PayPal Actually Do
GitHub uses Link headers for pagination (the standard HTTP approach) but doesn't include action links in response bodies. PayPal's HATEOAS implementation includes links in payment responses:
{
"id": "PAY-1AB23456CD789012EF34GHIJ",
"state": "created",
"links": [
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-...",
"rel": "self",
"method": "GET"
},
{
"href": "https://www.paypal.com/cgi-bin/webscr?cmd=...",
"rel": "approval_url",
"method": "REDIRECT"
},
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-.../execute",
"rel": "execute",
"method": "POST"
}
]
}
PayPal includes the HTTP method in each link, which makes it actually useful. The client can follow approval_url without hardcoding PayPal's redirect URL structure. This is a reasonable HATEOAS implementation for a public payment API where the API URL might change.
HATEOAS and API Versioning
The original promise of HATEOAS: because clients follow links rather than constructing URLs, the server can change URL structures freely without breaking clients. API versioning becomes unnecessary — just update the links.
The reality: clients still hardcode the entry-point URL. And once they've followed a link to /api/v2/orders/123/cancel, they've learned (and likely cached) that URL. Clients that hardcode discovered URLs aren't truly hypermedia clients.
Versioning via Content Negotiation vs URL Path
The HATEOAS purist position on versioning is to use content negotiation:
Accept: application/vnd.myapi.v2+json
Rather than changing URLs:
/api/v2/orders
The promise: the same URL serves v1 and v2 depending on what the client requests. Combined with HATEOAS, clients could theoretically migrate to v2 without changing any URL code, just by accepting the v2 content type.
The practice: developers hate this. It's impossible to test by pasting a URL in a browser. It breaks caching (requires correct Vary headers). It's less visible in logs. It's harder to route in API gateways.
URL path versioning wins in practice because it's simple and debuggable. See how to version REST APIs for a full breakdown of versioning strategies and API breaking changes without breaking clients for managing API evolution.
Building State-Machine APIs Without Full HATEOAS
The most practical way to capture HATEOAS's benefits without the full overhead is the available_actions pattern combined with state-specific response fields.
The available_actions Pattern in Detail
// Order response varies based on current state
interface OrderResponse {
id: string;
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
total: number;
available_actions: OrderAction[];
}
type OrderAction = 'pay' | 'cancel' | 'ship' | 'deliver' | 'refund' | 'dispute';
function getAvailableActions(order: Order): OrderAction[] {
switch (order.status) {
case 'pending':
return ['pay', 'cancel'];
case 'paid':
return ['ship', 'refund', 'cancel'];
case 'shipped':
return ['deliver'];
case 'delivered':
return ['refund', 'dispute'];
case 'cancelled':
return [];
default:
return [];
}
}
The frontend receives available_actions: ["pay", "cancel"] and shows or hides buttons accordingly. When order status changes (e.g., after payment), the next API call returns different actions. The client doesn't hardcode state machine logic.
OpenAPI Discriminators for State-Dependent Responses
You can formalize this in your OpenAPI spec using discriminators:
OrderResponse:
oneOf:
- $ref: '#/components/schemas/PendingOrder'
- $ref: '#/components/schemas/PaidOrder'
- $ref: '#/components/schemas/ShippedOrder'
discriminator:
propertyName: status
mapping:
pending: '#/components/schemas/PendingOrder'
paid: '#/components/schemas/PaidOrder'
shipped: '#/components/schemas/ShippedOrder'
PendingOrder:
type: object
properties:
status:
type: string
enum: [pending]
available_actions:
type: array
items:
type: string
enum: [pay, cancel]
This gives type-safe state-dependent responses that code generation tools can turn into discriminated union types in TypeScript.
Developer Experience Tradeoffs
Discoverability vs Predictability
Full HATEOAS prioritizes discoverability: a client can explore the API without reading documentation, following links wherever they lead. But predictability — knowing exactly what URL to call for an operation — is what developers actually want when building integrations.
TypeScript developers with an OpenAPI-generated client have better discoverability than a HATEOAS client in practice: they get autocomplete in their editor, inline documentation, type errors at compile time. This is a better developer experience than following links at runtime.
Type Safety vs Dynamic Navigation
HATEOAS is inherently dynamic: the set of available actions is determined at runtime from response links. TypeScript's type system works against this — you can't get compile-time safety for dynamically discovered endpoints. The available_actions pattern is a pragmatic compromise: the action names are typed (a string enum), but the implementation details remain on the server.
The Frontend Developer Perspective
Most frontend developers — who are the primary consumers of your API — find HATEOAS confusing in practice. They want to know: "what URL do I call to cancel an order?" The answer "follow the cancel link in the response" requires more code and provides less clarity than a documented POST /api/orders/{id}/cancel endpoint.
For API design principles that actually improve developer experience, see how to design a REST API developers love and API pagination patterns for the one place where HATEOAS-style links consistently add value.
The Verdict
HATEOAS isn't dead — it's niche. For most API teams building products consumed by their own frontends, it adds complexity without proportional benefit. OpenAPI specs, typed clients, and good documentation solve the same problems more practically.
Use HATEOAS when:
- Clients can't be updated alongside the API
- Complex state machines need runtime action discovery
- Building a truly generic API browser/explorer
Use the available_actions pattern when:
- Resources have meaningful state machines (orders, subscriptions, workflows)
- Frontend needs to know which buttons to show without duplicating state logic
- You want the benefit without the overhead
Skip HATEOAS when:
- You control both client and server
- You have typed API clients (TypeScript + code gen)
- You're building a standard CRUD API
The available_actions pattern is the practical sweet spot for 2026: explicit enough for type safety, dynamic enough to communicate valid state transitions without the client hardcoding business rules.