REST API Design
HATEOAS: Hypermedia-Driven APIs
Read aloud is unavailable in this browser.
The Web's Hidden Superpower
When you visit Amazon.com, your browser doesn't come pre-loaded with a map of every possible page and link on the site. Instead, it loads the homepage and discovers what's available through links: browse categories, search products, view cart, checkout. Each page tells you what you can do next.
This is hypermedia—the principle that made the World Wide Web revolutionary. Unlike earlier systems where clients needed to know all possible interactions in advance, the Web lets servers guide clients dynamically through links embedded in responses.
HATEOAS (Hypermedia as the Engine of Application State) applies this same principle to APIs. Instead of clients hardcoding URLs like /api/orders/123/cancel, responses include links that describe available actions:
{
"orderId": "ord-123",
"status": "pending",
"_links": {
"cancel": { "href": "/orders/ord-123/cancellations", "method": "POST" },
"pay": { "href": "/orders/ord-123/payments", "method": "POST" }
}
}
If the order has already shipped, those links disappear. If it's already cancelled, different links appear. The server controls the workflow; the client follows the trail.
By the end of this page, you will understand what HATEOAS actually means, why Roy Fielding considers it essential to REST, how to implement hypermedia in your APIs, common hypermedia formats (HAL, JSON-LD, Siren), the real-world tradeoffs, and when HATEOAS truly pays off versus when simpler approaches suffice.
What HATEOAS Actually Means
Let's break down the acronym:
Hypermedia As The Engine Of Application State
- Hypermedia — Content that contains links to other content (like HTML with
<a href>tags) - Engine — The driving mechanism, the thing that powers transitions
- Application State — The current context/stage in a workflow (not server state)
In plain English: The client's current state in a workflow is driven by following hypermedia links, not by pre-programmed knowledge of URLs.
The Fundamental Shift
Without HATEOAS, clients are tightly coupled to URL structures:
// Client with hardcoded URLs (non-HATEOAS)
const orderId = 'ord-123';
await fetch(`/api/v2/orders/${orderId}/cancel`, { method: 'POST' });
// If server changes URL structure, client breaks
With HATEOAS, clients discover URLs from responses:
// Client following hypermedia links (HATEOAS)
const order = await fetch('/api/orders/ord-123').then(r => r.json());
if (order._links.cancel) {
await fetch(order._links.cancel.href, {
method: order._links.cancel.method || 'POST'
});
} else {
console.log('Cancel not available for this order');
}
// Server can change URLs, add new actions—client adapts
Web browsers are the ultimate HATEOAS clients. They don't know your website's URL structure—they just render HTML and let users click links. If you move a page from /about to /company/about, browsers don't break; they just follow whatever links you provide. HATEOAS brings this power to API clients.
What Fielding Actually Said
Roy Fielding, REST's creator, has been emphatic about HATEOAS:
"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."
This is a strong statement. By Fielding's definition, most "REST APIs" today aren't truly RESTful—they're HTTP-based APIs at Richardson Maturity Level 2. This doesn't make them bad, just not formally REST.
The question for practitioners is: does the additional investment in HATEOAS provide enough value for your use case?
Benefits of Hypermedia-Driven APIs
HATEOAS provides significant advantages for the right use cases. Understanding these benefits helps you decide when the investment is worthwhile.
- •Decoupled Evolution — Servers can change URL structures, add new actions, remove deprecated features—clients that follow links adapt automatically.
- •Self-Documenting APIs — Responses themselves describe available actions; less reliance on external documentation.
- •Workflow Guidance — Links encode business rules (can this order be cancelled?) eliminating client-side state machine duplication.
- •Discoverability — New features appear as new links; clients can optionally support them without breaking changes.
- •Reduced Client Complexity — Clients don't need to implement URL construction logic; they just follow links.
- •Versioning Flexibility — Instead of /v1/, /v2/ paths, new capabilities appear as new link relations.
123456789101112131415161718192021222324252627282930313233343536373839
// Order in "pending_payment" state - what can we do?{ "orderId": "ord-123", "status": "pending_payment", "total": 99.99, "_links": { "self": {"href": "/orders/ord-123"}, "pay": {"href": "/orders/ord-123/payments", "method": "POST"}, "cancel": {"href": "/orders/ord-123/cancellations", "method": "POST"}, "modify": {"href": "/orders/ord-123/items", "method": "PATCH"} }} // Same order after payment - available actions changed{ "orderId": "ord-123", "status": "paid", "total": 99.99, "_links": { "self": {"href": "/orders/ord-123"}, "track": {"href": "/orders/ord-123/shipments"}, "invoice": {"href": "/orders/ord-123/invoice"}, "refund": {"href": "/orders/ord-123/refunds", "method": "POST"} }}// Notice: "cancel" and "modify" are gone; new actions appeared// Client doesn't need to know order state machine logic // Same order after shipping - even fewer (different) actions{ "orderId": "ord-123", "status": "shipped", "_links": { "self": {"href": "/orders/ord-123"}, "track": {"href": "/orders/ord-123/shipments"}, "return": {"href": "/orders/ord-123/returns", "method": "POST"}, "review": {"href": "/orders/ord-123/reviews", "method": "POST"} }}Without HATEOAS, clients must duplicate the order state machine: 'if status is pending AND payment is null AND created less than 24h ago, show cancel button.' With HATEOAS, the server simply includes or omits the 'cancel' link. One source of truth, no synchronization bugs.
Hypermedia Formats
While you can add hypermedia to any JSON response, standardized formats provide consistency and enable generic client libraries. Here are the major formats:
| Format | Media Type | Characteristics | Use Case |
|---|---|---|---|
| HAL | application/hal+json | Simple, widely adopted, _links and _embedded | General purpose APIs |
| JSON-LD | application/ld+json | Linked Data, semantic web, @context | Open data, SEO, semantic APIs |
| JSON:API | application/vnd.api+json | Relationships, sparse fieldsets, includes | Rich client apps |
| Siren | application/vnd.siren+json | Entities, actions with fields, classes | Complex workflows |
| Collection+JSON | application/vnd.collection+json | Collection-oriented, templates | CRUD-heavy APIs |
HAL (Hypertext Application Language) is the most widely adopted format due to its simplicity. It adds _links for navigation and _embedded for inline related resources.
123456789101112131415161718192021222324252627282930313233343536373839
{ "orderId": "ord-123", "status": "pending", "total": 149.99, "_links": { "self": { "href": "/orders/ord-123" }, "customer": { "href": "/customers/cust-456", "title": "View customer details" }, "items": { "href": "/orders/ord-123/items" }, "pay": { "href": "/orders/ord-123/payments", "method": "POST" }, "curies": [{ "name": "doc", "href": "https://docs.example.com/rels/{rel}", "templated": true }] }, "_embedded": { "items": [ { "productId": "prod-789", "name": "Widget Pro", "quantity": 2, "price": 74.995, "_links": { "self": {"href": "/products/prod-789"} } } ] }}Implementing HATEOAS
Let's implement HATEOAS in a practical, maintainable way. The key is encapsulating link generation so it's driven by business rules, not scattered across handlers.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
// Link generation based on resource state interface Link { href: string; method?: string; title?: string; templated?: boolean;} interface OrderLinks { self: Link; pay?: Link; cancel?: Link; track?: Link; refund?: Link; invoice?: Link; items: Link; customer: Link;} class Order { id: string; status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'; customerId: string; // ... other fields // Encapsulate link generation based on business rules getLinks(): OrderLinks { const links: OrderLinks = { self: { href: `/orders/${this.id}` }, items: { href: `/orders/${this.id}/items` }, customer: { href: `/customers/${this.customerId}` } }; // Conditional links based on current state switch (this.status) { case 'pending': links.pay = { href: `/orders/${this.id}/payments`, method: 'POST', title: 'Submit payment' }; links.cancel = { href: `/orders/${this.id}/cancellations`, method: 'POST', title: 'Cancel this order' }; break; case 'paid': links.track = { href: `/orders/${this.id}/shipments` }; links.invoice = { href: `/orders/${this.id}/invoice` }; // Refund only within policy window if (this.isWithinRefundWindow()) { links.refund = { href: `/orders/${this.id}/refunds`, method: 'POST' }; } break; case 'shipped': links.track = { href: `/orders/${this.id}/shipments` }; links.invoice = { href: `/orders/${this.id}/invoice` }; break; } return links; } toHAL(): object { return { orderId: this.id, status: this.status, total: this.total, createdAt: this.createdAt, _links: this.getLinks() }; } private isWithinRefundWindow(): boolean { const refundWindowDays = 30; const daysSincePaid = (Date.now() - this.paidAt.getTime()) / (1000 * 60 * 60 * 24); return daysSincePaid <= refundWindowDays; }} // Express handler returning HAL responseapp.get('/orders/:id', async (req, res) => { const order = await orderRepository.findById(req.params.id); if (!order) { return res.status(404).json({ error: 'NOT_FOUND', _links: { orders: { href: '/orders', title: 'Browse all orders' } } }); } res.type('application/hal+json') .json(order.toHAL());}); // Collection with pagination linksapp.get('/orders', async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; const { orders, total } = await orderRepository.findPaginated({ userId: req.user.id, page, limit }); const totalPages = Math.ceil(total / limit); res.type('application/hal+json').json({ _embedded: { orders: orders.map(o => o.toHAL()) }, _links: { self: { href: `/orders?page=${page}&limit=${limit}` }, first: { href: `/orders?page=1&limit=${limit}` }, ...(page > 1 && { prev: { href: `/orders?page=${page - 1}&limit=${limit}` } }), ...(page < totalPages && { next: { href: `/orders?page=${page + 1}&limit=${limit}` } }), last: { href: `/orders?page=${totalPages}&limit=${limit}` }, create: { href: '/orders', method: 'POST', title: 'Create new order' } }, page: { current: page, total: totalPages, size: limit, totalItems: total } });});Always encapsulate link generation within your domain models or dedicated builders. This keeps business rules (when can an order be cancelled?) in one place. When rules change, links update everywhere automatically.
Link Relations (rel)
Links need names that describe their semantic meaning. These are link relations (or rels). Standardized relations make APIs more predictable and interoperable.
IANA Link Relations
The Internet Assigned Numbers Authority (IANA) maintains a registry of standard link relations that have well-defined meanings:
| Relation | Meaning | Example Usage |
|---|---|---|
self | The resource itself | Current resource URL |
next | Next in a sequence | Pagination |
prev | Previous in a sequence | Pagination |
first | First in a sequence | Pagination |
last | Last in a sequence | Pagination |
collection | The containing collection | From item back to list |
item | A member of a collection | From list to item |
edit | Editable version | Link to update endpoint |
create | Create a new resource | Link to POST endpoint |
search | Search resource | Link to search endpoint |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Standard IANA relationsconst standardLinks = { // Navigation self: { href: '/orders/ord-123' }, collection: { href: '/orders' }, // Pagination (when applicable) first: { href: '/orders?page=1' }, prev: { href: '/orders?page=2' }, next: { href: '/orders?page=4' }, last: { href: '/orders?page=10' }}; // Custom relations with documentation (CURIEs)// CURIEs let you define custom relations with documentation linksconst responseWithCuries = { orderId: 'ord-123', status: 'pending', _links: { // CURIE definition curies: [ { name: 'acme', href: 'https://docs.acme.com/rels/{rel}', templated: true } ], // Standard relations (no prefix needed) self: { href: '/orders/ord-123' }, collection: { href: '/orders' }, // Custom relations (prefixed with CURIE name) 'acme:cancel': { href: '/orders/ord-123/cancellations', method: 'POST' }, 'acme:pay': { href: '/orders/ord-123/payments', method: 'POST' }, 'acme:expedite': { href: '/orders/ord-123/expedite', method: 'POST' } // Clients can look up 'acme:cancel' at: // https://docs.acme.com/rels/cancel }}; // Best practice: Use standard relations when possible// Prefix custom relations to avoid conflicts interface LinkRelations { // Standard (IANA) self: Link; collection?: Link; next?: Link; prev?: Link; first?: Link; last?: Link; // Domain-specific (custom, but common patterns) cancel?: Link; // Cancel a pending action pay?: Link; // Submit payment refund?: Link; // Request refund track?: Link; // Track shipment/status invoice?: Link; // View invoice receipt?: Link; // View receipt // Relationship links customer?: Link; // Related customer items?: Link; // Related items collection order?: Link; // Parent order (from item)}When using custom link relations (anything not in the IANA registry), document them thoroughly. Use CURIEs to link relations to documentation, or maintain a separate link relations reference in your API docs. Clients should be able to look up what 'acme:expedite' means.
Building Hypermedia-Aware Clients
The value of HATEOAS is only realized when clients follow links instead of constructing URLs. Here's how to build clients that leverage hypermedia properly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Hypermedia-aware API client class HypermediaClient { private baseUrl: string; private token: string; async get(url: string): Promise<HalResource> { const response = await fetch(this.baseUrl + url, { headers: { 'Accept': 'application/hal+json', 'Authorization': `Bearer ${this.token}` } }); return response.json(); } async follow(link: Link, body?: object): Promise<HalResource> { const response = await fetch(this.baseUrl + link.href, { method: link.method || 'GET', headers: { 'Accept': 'application/hal+json', 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` }, body: body ? JSON.stringify(body) : undefined }); return response.json(); }} // Usage: Client follows links, never constructs URLsclass OrderService { constructor(private client: HypermediaClient) {} async cancelOrder(orderId: string, reason: string): Promise<void> { // Step 1: Fetch the order const order = await this.client.get(`/orders/${orderId}`); // Step 2: Check if cancellation is available const cancelLink = order._links?.cancel; if (!cancelLink) { throw new Error( 'This order cannot be cancelled. ' + `Current status: ${order.status}` ); } // Step 3: Follow the cancel link await this.client.follow(cancelLink, { reason }); } async payForOrder(orderId: string, paymentDetails: PaymentDetails): Promise<void> { const order = await this.client.get(`/orders/${orderId}`); const payLink = order._links?.pay; if (!payLink) { if (order.status === 'paid') { throw new Error('Order is already paid'); } throw new Error('Payment is not available for this order'); } await this.client.follow(payLink, paymentDetails); }} // React component consuming hypermediafunction OrderActions({ order }: { order: HalOrder }) { const links = order._links; return ( <div className="order-actions"> {/* Only render buttons for available actions */} {links.pay && ( <button onClick={() => handlePay(links.pay)}> Pay Now </button> )} {links.cancel && ( <button onClick={() => handleCancel(links.cancel)}> Cancel Order </button> )} {links.track && ( <a href={links.track.href}> Track Shipment </a> )} {links.refund && ( <button onClick={() => handleRefund(links.refund)}> Request Refund </button> )} {/* UI adapts automatically based on available links */} </div> );}The React example shows the real power: the UI automatically shows/hides actions based on what links are present. No client-side state machine, no conditional logic based on order status. The server controls the workflow; the client just renders what's available.
Tradeoffs: When HATEOAS Makes Sense
HATEOAS isn't free. There are legitimate costs that may or may not be worth paying depending on your context.
- •Long-lived public APIs with many clients
- •Complex workflows with state-dependent actions
- •APIs that need to evolve without breaking clients
- •Self-service APIs with minimal human documentation
- •Multi-tenant APIs with different capabilities per tenant
- •Internal APIs with one or few clients
- •Simple CRUD with no complex workflows
- •Clients and server evolve in lockstep
- •High-frequency, latency-sensitive APIs
- •Mobile apps with embedded API knowledge
The Real Costs
1. Response Size — Adding _links increases payload size. For simple resources, links might be larger than the data. This matters for mobile networks and high-frequency APIs.
2. Client Complexity — While HATEOAS simplifies some client logic, it requires clients to parse and follow links rather than constructing known URLs. Not all client frameworks support this well.
3. Caching Challenges — When link availability depends on state, responses become user-specific and less cacheable. The same /orders/123 URL returns different links for different users.
4. Documentation Overhead — You need to document link relations, hypermedia formats, and expected flows. This can be more complex than documenting static URL patterns.
5. Testing Complexity — Testing that correct links appear in the right states adds to your test surface area.
You don't have to go all-in. Many APIs successfully use 'partial HATEOAS'—adding links for pagination and key actions while keeping simple resources link-free. Start with links where they provide clear value (pagination, workflows) and expand if needed.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.