REST API Hypermedia & HATEOAS: HAL, JSON:API & Practical Examples
Implement HATEOAS in your REST API using HAL, JSON:API, and custom link formats. Learn when hypermedia helps, when it hurts, and how to add it incrementally.
What you'll learn
- ✓What HATEOAS means and why Roy Fielding considers it essential to REST
- ✓How HAL (Hypertext Application Language) structures links and embedded resources
- ✓How JSON:API standardizes resource relationships and includes
- ✓Practical patterns for adding hypermedia links to your API
- ✓When HATEOAS pays off and when it adds unnecessary complexity
Prerequisites
- •Solid understanding of REST API design
- •Familiarity with HTTP methods and status codes
HATEOAS — Hypermedia as the Engine of Application State — is the most debated constraint in REST. Roy Fielding has said that an API that does not use hypermedia is not truly RESTful. Most production APIs ignore it entirely. The truth, as usual, is somewhere in between. This article explains what HATEOAS actually is, shows you three formats for implementing it, and helps you decide where it adds value.
What is HATEOAS?
In a traditional API, clients hardcode URLs:
// Client knows the URL structure
const user = await fetch('/api/users/123');
const orders = await fetch('/api/users/123/orders');
const order = await fetch('/api/orders/456');
await fetch('/api/orders/456/cancel', { method: 'POST' });
With HATEOAS, the server tells the client what it can do next by including links in every response:
{
"id": "ord_456",
"status": "pending",
"total": 89.99,
"links": {
"self": { "href": "/api/orders/ord_456" },
"cancel": { "href": "/api/orders/ord_456/cancel", "method": "POST" },
"payment": { "href": "/api/orders/ord_456/payment", "method": "PUT" },
"customer": { "href": "/api/users/usr_123" }
}
}
The client discovers available actions from the response instead of constructing URLs itself. If the order is already shipped, the cancel link disappears — the server controls what transitions are possible.
Why it matters
- Decoupling — clients do not break when you change URL structures
- Discoverability — new features appear as new links; clients can adapt
- State machines — the available links encode the resource’s current state
- Evolvability — you can version, move, or rename endpoints without breaking clients
Format 1: HAL (Hypertext Application Language)
HAL is the most popular hypermedia format. It uses _links for related URLs and _embedded for included resources.
Content type: application/hal+json
Single resource
{
"_links": {
"self": { "href": "/api/orders/ord_456" },
"customer": { "href": "/api/users/usr_123" },
"cancel": { "href": "/api/orders/ord_456/cancel" },
"items": { "href": "/api/orders/ord_456/items" }
},
"id": "ord_456",
"status": "pending",
"total": 89.99,
"currency": "USD",
"created_at": "2026-07-08T10:00:00Z"
}
Collection with embedded resources
{
"_links": {
"self": { "href": "/api/orders?page=2" },
"first": { "href": "/api/orders?page=1" },
"prev": { "href": "/api/orders?page=1" },
"next": { "href": "/api/orders?page=3" },
"last": { "href": "/api/orders?page=10" }
},
"_embedded": {
"orders": [
{
"_links": {
"self": { "href": "/api/orders/ord_456" },
"customer": { "href": "/api/users/usr_123" }
},
"id": "ord_456",
"status": "pending",
"total": 89.99
},
{
"_links": {
"self": { "href": "/api/orders/ord_789" },
"customer": { "href": "/api/users/usr_456" }
},
"id": "ord_789",
"status": "shipped",
"total": 149.50
}
]
},
"total_count": 98,
"page": 2,
"page_size": 10
}
HAL implementation in Express
function halResponse(res, resource, links, embedded = {}) {
const body = {
_links: {},
...resource,
};
// Convert links to HAL format
for (const [rel, value] of Object.entries(links)) {
if (typeof value === 'string') {
body._links[rel] = { href: value };
} else {
body._links[rel] = value;
}
}
if (Object.keys(embedded).length > 0) {
body._embedded = embedded;
}
return res.type('application/hal+json').json(body);
}
// Usage
app.get('/api/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ title: 'Not found' });
const links = {
self: `/api/orders/${order.id}`,
customer: `/api/users/${order.customer_id}`,
items: `/api/orders/${order.id}/items`,
};
// Conditionally add action links based on state
if (order.status === 'pending') {
links.cancel = { href: `/api/orders/${order.id}/cancel`, method: 'POST' };
links.payment = { href: `/api/orders/${order.id}/payment`, method: 'PUT' };
}
if (order.status === 'paid') {
links.ship = { href: `/api/orders/${order.id}/ship`, method: 'POST' };
links.refund = { href: `/api/orders/${order.id}/refund`, method: 'POST' };
}
halResponse(res, {
id: order.id,
status: order.status,
total: order.total,
currency: order.currency,
created_at: order.created_at,
}, links);
});
Format 2: JSON:API
JSON:API is a more opinionated specification that standardizes resource structure, relationships, and included resources.
Content type: application/vnd.api+json
Single resource
{
"data": {
"type": "orders",
"id": "ord_456",
"attributes": {
"status": "pending",
"total": 89.99,
"currency": "USD",
"created_at": "2026-07-08T10:00:00Z"
},
"relationships": {
"customer": {
"data": { "type": "users", "id": "usr_123" },
"links": {
"related": "/api/users/usr_123"
}
},
"items": {
"links": {
"related": "/api/orders/ord_456/items"
}
}
},
"links": {
"self": "/api/orders/ord_456"
}
},
"included": [
{
"type": "users",
"id": "usr_123",
"attributes": {
"name": "Jane Smith",
"email": "jane@example.com"
},
"links": {
"self": "/api/users/usr_123"
}
}
]
}
Collection
{
"data": [
{
"type": "orders",
"id": "ord_456",
"attributes": {
"status": "pending",
"total": 89.99
},
"links": {
"self": "/api/orders/ord_456"
}
}
],
"links": {
"self": "/api/orders?page[number]=2&page[size]=10",
"first": "/api/orders?page[number]=1&page[size]=10",
"prev": "/api/orders?page[number]=1&page[size]=10",
"next": "/api/orders?page[number]=3&page[size]=10",
"last": "/api/orders?page[number]=10&page[size]=10"
},
"meta": {
"total_count": 98,
"page_count": 10
}
}
Sparse fieldsets and includes
JSON:API supports query parameters for controlling what is returned:
GET /api/orders?include=customer,items&fields[orders]=status,total&fields[users]=name
This reduces payload size by only returning requested fields and relationships.
Format 3: Simple custom links
If HAL and JSON:API feel too heavy, add a simple links object to your existing response format:
{
"data": {
"id": "ord_456",
"status": "pending",
"total": 89.99
},
"links": {
"self": "https://api.example.com/orders/ord_456",
"cancel": "https://api.example.com/orders/ord_456/cancel",
"customer": "https://api.example.com/users/usr_123"
}
}
This is the most pragmatic approach for teams that want some hypermedia benefits without adopting a full specification.
Implementation helper
function addLinks(req, resource, linkDefs) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
const links = {};
for (const [rel, path] of Object.entries(linkDefs)) {
if (path !== null) {
links[rel] = `${baseUrl}${path}`;
}
}
return { data: resource, links };
}
app.get('/api/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id);
const response = addLinks(req, order, {
self: `/api/orders/${order.id}`,
customer: `/api/users/${order.customer_id}`,
cancel: order.status === 'pending' ? `/api/orders/${order.id}/cancel` : null,
ship: order.status === 'paid' ? `/api/orders/${order.id}/ship` : null,
});
res.json(response);
});
State-driven links: the real power
The most valuable aspect of HATEOAS is encoding state transitions. Consider an order that moves through states:
pending → paid → shipped → delivered
↘ cancelled
↘ refunded
Each state exposes different available actions:
const ORDER_LINKS = {
pending: (id) => ({
pay: `/api/orders/${id}/pay`,
cancel: `/api/orders/${id}/cancel`,
}),
paid: (id) => ({
ship: `/api/orders/${id}/ship`,
refund: `/api/orders/${id}/refund`,
}),
shipped: (id) => ({
deliver: `/api/orders/${id}/deliver`,
track: `/api/orders/${id}/tracking`,
}),
delivered: (id) => ({
return: `/api/orders/${id}/return`,
review: `/api/orders/${id}/review`,
}),
cancelled: () => ({}),
refunded: () => ({}),
};
function getOrderLinks(order) {
const stateLinks = ORDER_LINKS[order.status]?.(order.id) || {};
return {
self: `/api/orders/${order.id}`,
...stateLinks,
};
}
The client never needs to know which actions are valid for which state — it just checks which links are present in the response.
When HATEOAS pays off
Worth it:
- Public APIs consumed by many third-party developers
- APIs with complex state machines (orders, workflows, approvals)
- APIs that need to evolve without breaking clients
- Pagination links (universally useful)
Not worth it:
- Internal APIs with a single frontend consumer
- Simple CRUD with no state transitions
- High-performance APIs where every byte of payload matters
- Teams without the tooling to consume hypermedia links
Incremental adoption
You do not need to go full HATEOAS overnight. Start with these high-value additions:
- Pagination links —
self,next,prev,first,last - Self links — every resource includes its own URL
- Related resource links — replace IDs with URLs clients can follow
- Action links — state-dependent actions appear as links
// Before: client must construct URLs
{ "customer_id": "usr_123" }
// After: client follows the link
{ "customer_id": "usr_123", "links": { "customer": "/api/users/usr_123" } }
Comparing the formats
| Feature | Simple links | HAL | JSON:API |
|---|---|---|---|
| Spec complexity | Low | Medium | High |
| Embedded resources | No | Yes | Yes (included) |
| Sparse fieldsets | No | No | Yes |
| Relationship metadata | No | Minimal | Rich |
| Community tooling | Limited | Good | Extensive |
| Learning curve | None | Low | Moderate |
Summary
HATEOAS is not all-or-nothing. Start by adding pagination links and self links to every response — these are universally useful and cost almost nothing. If your API has complex state machines, add state-driven action links so clients discover available transitions from the response. Choose HAL for a lightweight standard, JSON:API when you need sparse fieldsets and rich relationship handling, or simple custom links when you want maximum control. The goal is not purity — it is giving clients enough information to navigate your API without hardcoding every URL.
Related articles
- REST APIs REST API HATEOAS Explained
Understand Hypermedia as the Engine of Application State, why most REST APIs skip it, and when adding hypermedia links actually pays off.
- REST APIs REST API Documentation with OpenAPI 3.1
Write machine-readable API documentation with OpenAPI 3.1. Learn the spec structure, Swagger UI setup, code generation, and best practices for keeping docs in sync.
- REST APIs REST API Error Handling Standards: RFC 7807 & Beyond
Design consistent, machine-readable error responses using RFC 7807 Problem Details, custom error codes, and structured error envelopes that clients can actually parse.
- REST APIs REST API Pagination Best Practices: Offset, Cursor & Keyset
Choose the right pagination strategy for your REST API. Compare offset, cursor, and keyset pagination with real examples, HATEOAS links, and performance trade-offs.