How to Implement HATEOAS in FastAPI (And Why Your API Needs It)
Make your REST APIs truly RESTful with hypermedia-driven navigation
How to Implement HATEOAS in FastAPI (And Why Your API Needs It)

Make your REST APIs truly RESTful with hypermedia-driven navigation
Most REST APIs return lists, metadata, and maybe some pagination numbers. But when a client wants page 2, what do they actually do?
Option A:
Manually construct URLs like /items?page=2&limit=10 and hope your API never changes.
Option B: Follow links your API gives them:
"next": "https://api.example.com/items?page=2&limit=10"
Option B is HATEOAS — and it transforms a brittle client into a resilient one.
In this guide, I’ll show you how to implement clean, reusable HATEOAS pagination in FastAPI using a pattern you can drop into any project.
What Is HATEOAS?
HATEOAS stands for Hypermedia as the Engine of Application State, one of REST’s original constraints. The idea: clients should discover navigation paths from the API responses themselves, not construct URLs by hand.
Example:
{
"items": [
{"id": "abc-123", "name": "Product A"},
{"id": "def-456", "name": "Product B"}
],
"total_count": 150,
"page": 1,
"limit": 10,
"links": {
"first": "https://api.example.com/products?page=1&limit=10",
"next": "https://api.example.com/products?page=2&limit=10",
"last": "https://api.example.com/products?page=15&limit=10"
}
}
The client doesn’t know or care how URLs are built — they simply follow the links.
Why HATEOAS Matters
1. Clients Become Decoupled
If you change your URL structure, routes, or query parameters, HATEOAS-aware clients keep working.
2. Self-Documenting APIs
Clients instantly know available actions — without reading docs.
3. Filters Are Preserved Automatically
Filtering with: ?category=electronics&status=active shouldn’t disappear the moment you paginate. HATEOAS takes care of it.
4. Frontend Developers Love It
No more string concatenation. No more fragile URL reconstruction.
Implementing HATEOAS in FastAPI
We’ll build a clean, reusable system with:
- A Pydantic model for pagination params
- A link-builder
- A response wrapper
- A FastAPI dependency for effortless integration
Step 1: Define Pagination Parameters
from enum import Enum
from pydantic import BaseModel, PositiveInt
class Ordering(str, Enum):
asc = "asc"
desc = "desc"
class ListingParams(BaseModel):
page: PositiveInt = 1
limit: PositiveInt = 10
order_by: str = "created_at"
ordering: Ordering = Ordering.desc
Step 2: Build Pagination Links
from typing import Any
from fastapi import Request
def build_pagination_links(
request: Request,
page: int,
limit: int,
total_count: int,
) -> dict[str, str]:
base_url = str(request.url.remove_query_params(["page", "limit"]))
query_params = dict(request.query_params)
query_params.pop("page", None)
query_params.pop("limit", None)
extra_params = "&".join(f"{k}={v}" for k, v in query_params.items())
separator = "&" if extra_params else ""
total_pages = (total_count + limit - 1) // limit if total_count > 0 else 1
links = {
"first": f"{base_url}?page=1&limit={limit}{separator}{extra_params}",
"last": f"{base_url}?page={total_pages}&limit={limit}{separator}{extra_params}",
}
if page < total_pages:
links["next"] = (
f"{base_url}?page={page + 1}&limit={limit}{separator}{extra_params}"
)
if page > 1:
links["previous"] = (
f"{base_url}?page={page - 1}&limit={limit}{separator}{extra_params}"
)
return links
Step 3: Wrap Everything in a Response Builder
def create_hateoas_response(
request: Request,
listing_params: ListingParams,
items: list[dict[str, Any]],
total_count: int,
) -> dict[str, Any]:
links = build_pagination_links(
request,
listing_params.page,
listing_params.limit,
total_count
)
return {
"items": items,
"total_count": total_count,
"page": listing_params.page,
"limit": listing_params.limit,
"links": links,
}
This is the final structure your endpoints will return.
Step 4: Build a FastAPI Dependency
from functools import partial
from fastapi import Depends, Request
def hateoas_dependency(
# We should inject the request and the listing params
request: Request,
listing_params: ListingParams = Depends()
):
return partial(
create_hateoas_response,
request=request,
listing_params=listing_params
)
Using partial() pre-configures context—clean and elegant.
Using HATEOAS in Your Endpoints
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/products")
async def list_products(
category_filter: str | None = None,
status_filter: str | None = None,
hateoas = Depends(hateoas_dependency),
use_case: ListProductsUseCase = Depends(get_list_products_usecase),
):
result = await use_case.execute(
category=category,
status=status
)
items = [
{
"id": str(product.id),
"name": product.name,
"category_filter": product.category,
"status_filter": product.price
}
for product in result.items
]
return hateoas(items=items, total_count=result.count)
The endpoint stays beautifully clean.
Example Response
{
"items": [
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"name": "Wireless Headphones",
"category": "electronics",
"price": 79.99
}
],
"total_count": 47,
"page": 2,
"limit": 5,
"links": {
"first": "...",
"previous": "...",
"next": "...",
"last": "..."
}
}
All filters from the original request remain intact.
Adding Resource-Level Links (Optional, Powerful)
def add_resource_links(
request: Request,
items: list[dict[str, Any]],
resource_path: str
) -> list[dict[str, Any]]:
"""Add self links to each item."""
base = str(request.base_url).rstrip("/")
for item in items:
item["links"] = {
"self": f"{base}/{resource_path}/{item['id']}"
}
return items
This enables clients to traverse from lists to individual resources.
Is HATEOAS Only for Listing Endpoints?
Not at all. While HATEOAS is commonly showcased through pagination examples, it applies to any resource and any action in your API. Its purpose is to help clients discover what they can do next — without hardcoding URLs or workflow rules.
Here are a few places where HATEOAS becomes even more powerful:
1. Single Resource Responses
You can include links that describe available actions on a resource:
{
"id": "123",
"name": "Product A",
"links": {
"self": "/products/123",
"update": "/products/123",
"delete": "/products/123"
}
}
2. Workflow or State Transitions
For resources that follow a lifecycle (e.g., draft → review → published), HATEOAS exposes only the valid next actions:
{
"id": "123",
"state": "draft",
"links": {
"submit_for_review": "/posts/123/submit"
}
}
3. Navigation Between Related Resources
Useful for nested or linked data:
{
"id": "888",
"comment": "Looks good",
"links": {
"self": "/tasks/4/comments/888",
"task": "/tasks/4",
"project": "/projects/1"
}
}
One last note, this article uses offset pagination because it’s simple, but it’s not always the best option. For large or fast-changing datasets, cursor or keyset pagination can give you far better performance and consistency. I’ll cover the differences (and how to implement each in FastAPI) in a separate article.
메타데이터
- post_id
- 7be5fac3a37d
- slug
- how-to-implement-hateoas-in-fastapi-and-why-your-api-needs-it-7be5fac3a37d
- url
- https://medium.com/@benothman.lotfi/how-to-implement-hateoas-in-fastapi-and-why-your-api-needs-it-7be5fac3a37d
- canonical_url
- https://medium.com/@benothman.lotfi/how-to-implement-hateoas-in-fastapi-and-why-your-api-needs-it-7be5fac3a37d
- author_url
- https://medium.com/@benothman.lotfi
- status
- ok
- fetched_at
- 2026-06-09 15:37:30