Teams often claim “we have a REST API in place.” But when you look at the actual JSON responses, there are no links anywhere. Just raw data. That’s not REST, it’s CRUD exposed over HTTP.
The difference comes down to one principle most developers overlook: HATEOAS.
What Is HATEOAS in a REST API?
HATEOAS stands for Hypermedia As The Engine Of Application State. It is one of the fundamental constraints of REST, defined by Roy Fielding in his 2000 dissertation, the same paper that coined the term REST itself.
The principle is straightforward: a REST client should not need to know the API routes in advance. It starts from an entry point and discovers available actions by following the links provided in each response.
It works exactly like browsing the web. You land on a page, read the available links, click, and move forward. You don’t manually type URLs at every step.
CRUD over HTTP vs Real REST API Design
Without HATEOAS, a typical response looks like this:
{
"id": 1,
"status": "pending",
"montant": 1500.00,
"client_id": 7
}
The client receiving this must already know:
- that to validate, it needs to call
POST /contrats/1/valider/ - that to cancel, it’s
DELETE /contrats/1/ - that the client is accessible via
GET /clients/7/
That knowledge is hardcoded on the client side. If the API changes a route, the client breaks. That’s not evolvability, it’s tight coupling in disguise.
A Real HATEOAS Response
With HATEOAS, the same response becomes self-descriptive:
{
"id": 1,
"status": "pending",
"montant": 1500.00,
"client_id": 7,
"_links": {
"self": { "href": "/api/contrats/1/", "method": "GET" },
"valider": { "href": "/api/contrats/1/valider/", "method": "POST" },
"annuler": { "href": "/api/contrats/1/", "method": "DELETE" },
"client": { "href": "/api/clients/7/", "method": "GET" }
}
}
The client no longer needs to know the routes. It reads the available links and knows which actions are possible given the current state of the resource. If the contract is already validated, the valider link simply does not appear in the response. The client doesn’t even have to check.
HATEOAS in Practice: Links Reflect Resource State
This is where HATEOAS becomes genuinely powerful. The links change based on the resource’s state:
// Contract with status "pending"
"_links": {
"self": { "href": "/api/contrats/1/", "method": "GET" },
"valider": { "href": "/api/contrats/1/valider/", "method": "POST" },
"annuler": { "href": "/api/contrats/1/", "method": "DELETE" }
}
// Same contract, status "validated"
"_links": {
"self": { "href": "/api/contrats/1/", "method": "GET" },
"resilier": { "href": "/api/contrats/1/resilier/", "method": "POST" }
}
The client does not code conditional logic (if status == "pending": show_validate_button). It reads the available links and builds its interface accordingly. The API drives the application state, which is precisely what the name HATEOAS means.
HATEOAS Implementation with Django REST Framework
DRF does not include HATEOAS natively, but it can be implemented cleanly with a dedicated serializer:
from rest_framework import serializers
from .models import Contrat, ContratStatus
class ContratSerializer(serializers.ModelSerializer):
_links = serializers.SerializerMethodField()
class Meta:
model = Contrat
fields = ['id', 'status', 'montant', 'client_id', '_links']
def get__links(self, obj: Contrat) -> dict[str, dict[str, str]]:
base = f'/api/contrats/{obj.pk}/'
links: dict[str, dict[str, str]] = {
'self': {'href': base, 'method': 'GET'},
}
if obj.status == ContratStatus.PENDING:
links['valider'] = {'href': f'{base}valider/', 'method': 'POST'}
links['annuler'] = {'href': base, 'method': 'DELETE'}
if obj.status == ContratStatus.VALIDATED:
links['resilier'] = {'href': f'{base}resilier/', 'method': 'POST'}
if obj.client_id:
links['client'] = {'href': f'/api/clients/{obj.client_id}/', 'method': 'GET'}
return links
Every time a contract is serialized, the links reflect its current state. No conditional logic required on the client side.
Richardson Maturity Model: Where Does Your REST API Stand?
| Level | Description | Example |
|---|---|---|
| 0 | Single endpoint, everything via POST | SOAP, XML-RPC |
| 1 | Distinct resources | GET /contrats/1 |
| 2 | Correct HTTP verbs | POST /contrats/, DELETE /contrats/1 |
| 3 | HATEOAS | Responses include _links |
Most production APIs sit at level 2. Level 3 is what Fielding actually calls “REST.”
Should You Always Implement HATEOAS?
Honestly, no. It is particularly well suited when:
- The API is public and consumed by unknown third-party clients
- The workflow is complex and likely to evolve
- You want to reduce coupling between client and API
For an internal API, levels 1 and 2 are often sufficient. But understanding HATEOAS changes the way you think about API design.
Working on Django query optimization? Check out Django in_bulk(): why it beats filter() for bulk lookups.