Implementing HATEOAS in REST APIs: A Practical Guide

A practical guide to implementing HATEOAS in REST APIs with formats, examples, tooling, testing, and rollout strategies.

ASOasis
8 min read
Implementing HATEOAS in REST APIs: A Practical Guide

Image used for representation purposes only.

Overview

HATEOAS (Hypermedia as the Engine of Application State) is a REST constraint that lets clients navigate and act on resources through links and affordances embedded directly in server responses. Rather than hard-coding URL templates or workflow logic, clients follow links the server provides at runtime. This decouples client behavior from server URL structures and enables safer evolution over time.

This article walks through the practical side of implementing HATEOAS: choosing representation formats, modeling link relations, emitting links and actions, handling errors, testing, performance, security, and an incremental rollout strategy—with concrete JSON examples.

Why HATEOAS still matters

  • Evolvability: Servers can reorganize routes or introduce new capabilities without breaking clients that rely on links and relation types instead of fixed paths.
  • Discoverability: Clients can explore capabilities dynamically—useful for SDKs, CLIs, and low-code tooling.
  • Self-descriptive messages: Each response explains “what you can do next,” reducing out-of-band documentation.
  • Reduced coupling: Clients don’t need to stitch URLs; they choose an action by relation name, not by path structure.

Core design principles

  • Links are first-class: Every resource includes a self link and related links using standard or well-defined custom rels.
  • Affordances reflect state: Only expose actions that are valid in the current state (e.g., cancel is present only if an order is cancelable).
  • Stable relation semantics: Clients key off relation names (rel), not URL shapes. Prefer registered rels where possible.
  • Self-descriptive messages: Representations include enough metadata (media type, rels, titles, hints) to guide clients.
  • Content negotiation: Use media types that clearly express links and forms.

Pick a representation format

Several mature hypermedia formats exist. Choose one that matches your needs and ecosystem tooling.

  • HAL (application/hal+json)
    • Pros: Simple, compact, widely understood; good for link embedding and basic embedding of related resources.
    • Cons: Limited affordance modeling (actions/forms need conventions or extensions).
  • Siren (application/vnd.siren+json)
    • Pros: Rich actions (name, method, href, fields), entities, and class semantics.
    • Cons: Slightly heavier; fewer off-the-shelf tools than HAL.
  • Collection+JSON (application/vnd.collection+json)
    • Pros: Designed for collections with queries and templates.
    • Cons: Less common for complex domain models.
  • JSON-LD + Hydra
    • Pros: Strong semantics via RDF; discoverable API documentation; good for linked data.
    • Cons: Higher learning curve.

Tip: Start with HAL if you want minimal friction; adopt Siren/Hydra if you need rich, typed actions.

  • Prefer standard rels where available (e.g., self, next, prev, collection).
  • For domain-specific actions (e.g., pay, cancel), define custom rels as namespaced URIs or CURIEs (Compact URIs) to avoid collisions.
  • Keep relation names stable across versions; evolve semantics via documentation and profiles.

Example using CURIEs in HAL:

{
  "_links": {
    "self": { "href": "/orders/123" },
    "curies": [{ "name": "ex", "href": "https://api.example.com/rels/{rel}", "templated": true }],
    "ex:pay": { "href": "/orders/123/payment" },
    "ex:cancel": { "href": "/orders/123/cancel" },
    "customer": { "href": "/customers/42" }
  }
}
  1. Centralize link building
  • Create a LinkBuilder utility that can generate absolute URLs from route names/IDs.
  • Avoid concatenating strings in handlers.
  1. Emit state-dependent links
  • In your domain layer, expose an API like allowedActions(order) → [“pay”, “cancel”].
  • In your representation layer, include only those actions as links or forms.
  1. Support multiple media types
  • Implement content negotiation via Accept headers.
  • Start with JSON default, add application/hal+json or Siren as opt-in.
  1. Include a root entry point
  • Provide a well-known landing resource (/) that advertises top-level capabilities.

Example: Orders API in HAL

State model: PLACED → PAID → SHIPPED; Cancel allowed in PLACED; Pay allowed in PLACED.

GET /orders/123 (PLACED):

{
  "id": 123,
  "status": "PLACED",
  "total": 49.99,
  "currency": "USD",
  "_links": {
    "self": { "href": "/orders/123" },
    "customer": { "href": "/customers/42" },
    "collection": { "href": "/orders" },
    "pay": { "href": "/orders/123/payment" },
    "cancel": { "href": "/orders/123/cancel" }
  }
}

After payment, GET /orders/123 (PAID):

{
  "id": 123,
  "status": "PAID",
  "total": 49.99,
  "_links": {
    "self": { "href": "/orders/123" },
    "customer": { "href": "/customers/42" },
    "collection": { "href": "/orders" },
    "invoice": { "href": "/orders/123/invoice" }
  }
}

Example: Orders API in Siren (with actions)

{
  "class": ["order"],
  "properties": { "id": 123, "status": "PLACED", "total": 49.99 },
  "entities": [
    {
      "rel": ["customer"],
      "href": "/customers/42"
    }
  ],
  "actions": [
    {
      "name": "pay",
      "title": "Pay for this order",
      "method": "POST",
      "href": "/orders/123/payment",
      "type": "application/json",
      "fields": [ { "name": "cardToken", "type": "text", "required": true } ]
    },
    {
      "name": "cancel",
      "title": "Cancel this order",
      "method": "POST",
      "href": "/orders/123/cancel"
    }
  ],
  "links": [
    { "rel": ["self"], "href": "/orders/123" },
    { "rel": ["collection"], "href": "/orders" }
  ]
}

HTTP details that matter

  • Link header: Duplicate critical links in headers for intermediaries and generic clients.

Example:

Link: </orders/123>; rel="self", </orders>; rel="collection", </orders/123/payment>; rel="pay"
  • Caching: Use ETag/Last-Modified and Cache-Control; conditional requests keep hypermedia fresh at low cost.
ETag: "W/\"ord-123-v5\""
Cache-Control: max-age=60
  • Content negotiation: Return 406 Not Acceptable when a requested hypermedia type isn’t supported; advertise supported types via Accept and OPTIONS/Allow when useful.

  • Profiles and documentation: Provide a profile link rel to describe semantics.

Link: <https://api.example.com/profiles/order>; rel="profile"

Pagination, filtering, and bulk operations

  • Always include pagination links: first, prev, next, last.
  • For server-driven paging, indicate page size and cursors in links.
{
  "count": 25,
  "pageSize": 10,
  "_links": {
    "self": { "href": "/orders?page=2" },
    "first": { "href": "/orders?page=1" },
    "prev": { "href": "/orders?page=1" },
    "next": { "href": "/orders?page=3" },
    "last": { "href": "/orders?page=3" }
  },
  "_embedded": {
    "orders": [ { "id": 121, "_links": { "self": { "href": "/orders/121" } } } ]
  }
}

For bulk actions, expose a collection-level action (e.g., cancel-many) with a payload template (Siren action or Collection+JSON template).

Use application/problem+json to standardize errors and include links for remediation.

{
  "type": "https://api.example.com/problems/payment-declined",
  "title": "Payment declined",
  "status": 402,
  "detail": "Your card was declined.",
  "instance": "/orders/123/payment/attempts/9",
  "_links": {
    "retry": { "href": "/orders/123/payment" },
    "change-payment-method": { "href": "/customers/42/payment-methods" }
  }
}

Security and authorization considerations

  • Emit only links the caller is authorized to use; authorization shapes hypermedia.
  • For unsafe actions (POST/PUT/PATCH/DELETE), include CSRF protections where applicable.
  • Avoid exposing sensitive identifiers in link URLs; use opaque IDs.
  • Consider signed or one-time action links for high-risk operations.
  • Never rely on link presence alone for authorization—server must still enforce permissions.

Performance strategies

  • Lean by default: Include links, not whole graphs. Offer embedding/expansion opt-ins (e.g., ?embed=customer or HAL’s _embedded) to control chattiness.
  • Batch-friendly link expansion: Support multiple IDs in one request when appropriate.
  • Use HTTP caching aggressively; hyperlinks don’t preclude freshness.
  • Prefer cursor pagination over deep offsets for large datasets.
  • Measure! Profile latency added by link computation; cache link fragments if needed.

Client behavior and traversal pattern

Design clients to traverse by relation, not by constructing URLs. A minimal algorithm:

start = GET /
orders = follow(start, rel="orders")
firstPage = follow(orders, rel="first")
for each order in firstPage.embedded.orders:
  if has_link(order, rel="pay"):
    show_pay_button(order)

Client SDKs can provide a follow(resource, rel, params?) helper that resolves templated links and handles retries, redirects, and authentication.

Versioning and evolvability

  • Keep relation names stable. Add new rels; avoid removing existing ones.
  • Use media type versioning (custom vendor types) or profiles to communicate semantic changes.
  • Deprecate via headers and links: include a deprecation link and optionally a Sunset header for planned removal.
Link: </rels/pay>; rel="deprecation"
Sunset: Tue, 31 Mar 2026 23:59:59 GMT
  • Prefer additive change: new links, new embedded fields, new actions. Clients that ignore unknown members keep working.

Tooling by ecosystem (examples)

  • Java: Spring HATEOAS (link building, assemblers, affordances), Spring Data REST.
  • .NET: LinkGenerator, IUrlHelper; wrap in a link service; libraries exist to model HAL/Siren.
  • JavaScript/Node.js: Middleware patterns for HAL/Siren; generate links from route names; OpenAPI-to-HAL mappers exist.
  • Python: Flask or FastAPI with helper utilities; Eve includes built-in HATEOAS; DRF add-ons (e.g., HAL renderers) are available.
  • Go: Use router naming plus small helpers to emit HAL/Link headers; lightweight HAL libraries exist.

Choose tools that let you name routes and resolve them from code, keep link emission close to your representation layer, and unit test link generation.

Testing and quality gates

  • Contract tests: For each resource state, assert presence/absence of expected rels.
  • Link integrity tests: Crawl the API in CI and verify 2xx/3xx for advertised links (auth-aware).
  • Schema/format validation: Validate HAL/Siren/Problem+JSON with JSON Schema where applicable.
  • Backward-compat checks: Ensure new deployments don’t remove previously advertised rels.

Example test (pseudocode):

o = GET /orders/123 (PLACED)
assert has_link(o, "pay")
assert has_link(o, "cancel")
POST o._links.pay.href { cardToken: "tok_123" } → 201
n = GET /orders/123
assert not has_link(n, "cancel")

Incremental rollout plan

  1. Add self links to all resources; ship.
  2. Introduce collection navigation (first/prev/next/last); ship.
  3. Add domain links (customer, invoice, etc.); ship.
  4. Add stateful actions (pay, cancel) using your chosen format; ship.
  5. Introduce root entry point and profiles; ship.
  6. Migrate clients to traversal helpers; remove hard-coded URLs.

Common pitfalls

  • Treating links as documentation-only: Clients must actually traverse them.
  • Emitting links the client isn’t authorized to use.
  • Over-embedding large graphs by default; hurts performance.
  • Inconsistent rel naming across endpoints.
  • Hiding critical navigation behind query conventions instead of explicit links.

A concise checklist

  • Root entry point with top-level rels.
  • Every resource has: self, collection, and relevant domain rels.
  • State-aware actions/links; unauthorized actions omitted.
  • Consistent media types and content negotiation.
  • Pagination links on all collection responses.
  • Problem+JSON for errors, with remediation links.
  • Caching via ETag/Cache-Control; optional Link headers.
  • Contract tests for link presence and integrity.

Conclusion

HATEOAS isn’t about making responses verbose—it’s about making them self-descriptive and resilient to change. By standardizing on a hypermedia format, centralizing link generation, emitting stateful affordances, and testing links as contracts, you enable clients to adapt as your API evolves. Start small with self links and pagination, then grow into richer actions and profiles. The payoff is an API that remains flexible, discoverable, and stable as your product and teams scale.

Related Posts