Loading content...
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.
Let's break down the acronym:
Hypermedia As The Engine Of Application State
<a href> tags)In plain English: The client's current state in a workflow is driven by following hypermedia links, not by pre-programmed knowledge of URLs.
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.
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?
HATEOAS provides significant advantages for the right use cases. Understanding these benefits helps you decide when the investment is worthwhile.
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.
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"} } } ] }}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.
Links need names that describe their semantic meaning. These are link relations (or rels). Standardized relations make APIs more predictable and interoperable.
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.
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.
HATEOAS isn't free. There are legitimate costs that may or may not be worth paying depending on your context.
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.
Let's examine how major APIs approach hypermedia:
GitHub includes hypermedia links in all responses, making it one of the most RESTful public APIs:
{
"id": 12345,
"name": "my-repo",
"full_name": "user/my-repo",
"url": "https://api.github.com/repos/user/my-repo",
"html_url": "https://github.com/user/my-repo",
"issues_url": "https://api.github.com/repos/user/my-repo/issues{/number}",
"pulls_url": "https://api.github.com/repos/user/my-repo/pulls{/number}",
"commits_url": "https://api.github.com/repos/user/my-repo/commits{/sha}"
}
PayPal uses HATEOAS extensively for payment workflows, where state-dependent actions are critical:
{
"id": "PAY-123",
"state": "created",
"links": [
{"rel": "self", "href": "...", "method": "GET"},
{"rel": "approval_url", "href": "...", "method": "REDIRECT"},
{"rel": "execute", "href": "...", "method": "POST"}
]
}
Interestingly, Stripe—despite being a beloved API—doesn't use HATEOAS. They prioritize simplicity, excellent documentation, and SDK-first development. Their API is predictable enough that clients don't need discovery.
This highlights an important lesson: HATEOAS is one path to API excellence, not the only one. The best approach depends on your context, clients, and how your API will evolve.
Stripe proves you can build a world-class API without HATEOAS. GitHub proves hypermedia works at massive scale. The right choice depends on your API's longevity, complexity, audience, and evolution patterns. Understand the tradeoffs, then choose deliberately.
HATEOAS is REST's most advanced concept—the final step to truly self-describing APIs. Let's consolidate the key learnings:
Module Complete:
You've now completed the REST API Design module. You understand:
These principles will serve you whether you're building simple internal APIs or complex public platforms. The key is understanding the tradeoffs and making deliberate choices for your context.
Congratulations! You now understand REST API design at an advanced level—from Fielding's original constraints through practical implementation patterns. You can design APIs that are intuitive, evolvable, and robust. Apply these principles thoughtfully, and your APIs will stand the test of time.