HATEOAS in Practice: When Hypermedia Links Earn Their Keep (and When They’re Ceremony)

HATEOAS is the most-cited and least-implemented letter in REST. It is the part everyone quotes when they want to sound rigorous, and the part almost everyone quietly drops the moment a frontend team asks how they are meant to consume it.

I have shipped APIs both with and without hypermedia links, and the honest position is not “always” or “never”. It is that a small, well-chosen set of links pays for itself in exactly a few situations, and the full hypermedia dream — clients that discover the entire API at runtime from a single entry point — rarely survives contact with a real product team on a deadline. This is a field note on where _links earned their keep in a NestJS 10 service, where they added noise, and how to tell the difference before you commit a payload shape you will have to support for years.

What HATEOAS actually asks for

HATEOAS — Hypermedia As The Engine Of Application State — is the constraint that a client should be driven by links the server returns, rather than by URLs the client has hard-coded. In principle you fetch a resource, read the links it offers, and follow them; the server owns the URL structure and the set of available transitions, and the client owns almost no knowledge of either.

Free · 4 minutes

If your most senior engineer left tomorrow, would anyone still understand the system?

Fourteen questions on documentation, dependencies, and the gap between how the architecture works and how many people know it. Banded finding on screen, full sheet by email.

The most common concrete encoding is HAL (Hypertext Application Language, the draft-kelly-json-hal convention, served as application/hal+json). HAL is deliberately thin: a reserved _links object, where each relation carries an href, optionally templated for URI templates, plus _embedded for nested resources. That thinness is the point. You can adopt the _links shape without buying into the whole runtime-discovery ideology, and in practice that partial adoption is the only version worth having.

Where the links earn their keep: state machines

The one place hypermedia consistently pays off is a resource that moves through a workflow — an invoice, an order, an onboarding case, a payout. These resources have a state machine, and the set of valid next actions depends entirely on the current state. A draft invoice can be issued or deleted; an issued invoice can be paid or voided; a paid invoice can do neither. Without links, that logic lives in two places: the server enforces it, and every client re-implements it to decide which buttons to render. The two copies drift, and the client eventually offers a “Void” button that the server rejects with a 409.

A state-dependent _links block collapses that duplication. The server advertises only the transitions the current state actually permits, and the client renders whatever it is given. The rule that matters is simple: the presence of a link is the permission. If pay is absent, the client does not offer payment. This is where the pattern moves from academic to genuinely useful.

A lightweight HAL block in NestJS 10

Here is the shape I actually use — a plain mapper on the response DTO, not a framework, not a decorator library. It runs in NestJS 10 on Node 22 and has no dependencies beyond the language. The point is that it is boring and readable, and that the transition logic lives in exactly one place.

// invoice-response.mapper.ts
type HalLink = { href: string; method?: string; templated?: boolean };

export interface InvoiceResponse {
  id: string;
  status: 'draft' | 'issued' | 'paid' | 'void';
  amountMinor: number;
  currency: string;
  _links: Record<string, HalLink>;
}

export function toInvoiceResponse(
  invoice: Invoice,
  baseUrl: string,
): InvoiceResponse {
  const self = `${baseUrl}/invoices/${invoice.id}`;
  const _links: Record<string, HalLink> = { self: { href: self } };

  // Advertise only the transitions this state actually allows.
  if (invoice.status === 'draft') {
    _links.issue = { href: `${self}/issue`, method: 'POST' };
    _links.remove = { href: self, method: 'DELETE' };
  }
  if (invoice.status === 'issued') {
    _links.pay = { href: `${self}/payments`, method: 'POST' };
    _links.void = { href: `${self}/void`, method: 'POST' };
  }

  return {
    id: invoice.id,
    status: invoice.status,
    amountMinor: invoice.amountMinor,
    currency: invoice.currency,
    _links,
  };
}

An issued invoice then serialises to this, and the client needs to know nothing about the state machine at all:

{
  "id": "inv_9Q2",
  "status": "issued",
  "amountMinor": 45000,
  "currency": "EUR",
  "_links": {
    "self": { "href": "https://api.example.com/invoices/inv_9Q2" },
    "pay":  { "href": "https://api.example.com/invoices/inv_9Q2/payments", "method": "POST" },
    "void": { "href": "https://api.example.com/invoices/inv_9Q2/void", "method": "POST" }
  }
}

Two honest caveats. First, method is not part of the HAL specification — HAL links describe location, not verb — so if you add it you are extending the convention, and you must document that for consumers. Second, the links tell the client which actions are offered; they do not make those actions safe. A pay that a nervous client retries after a timeout still needs a server that will not charge twice, which is a matter of idempotency keys, not hypermedia.

Where it is just ceremony

Pagination is the honourable middle case. A next and prev link on a collection genuinely decouples the client from cursor mechanics, and it is cheap, so I add it. Beyond those two situations, the returns fall off a cliff.

On a static reference resource with no transitions and no navigation, a _links block that contains only self is pure ceremony. It adds bytes, it adds a nesting level every client has to reach through, and it advertises nothing the client did not already know. Worse is the reflexive version where every resource sprouts _links because the team decided the API “is HATEOAS now” — you get payloads that are half links and half data, and no client ever reads most of them.

The deeper reason full HATEOAS maturity rarely survives is that frontend teams do not, in fact, write clients that discover the API at runtime. They read the docs, hard-code the routes they need, and ship. When you tell them the URLs are “discoverable” and therefore undocumented, you have not decoupled anything — you have removed the reference material they were relying on and given them a runtime treasure hunt instead. The theory assumes a generic client; almost every real client is purpose-built for one API and one product.

The rule I actually apply

Add links where they remove duplicated logic — state transitions and pagination — and nowhere else. Keep the shape thin, document any extension you make to HAL, and treat the links as a convenience the client may use, never as the only way to use the API. Version and secure the API as you would any other: the links do not replace a stable URL contract, and they certainly do not replace verifying the caller’s token or scoping the key that reached the endpoint.

HATEOAS is not a maturity level to climb towards. It is a tool that solves the specific problem of state-dependent actions, and outside that problem it is decoration you will be maintaining long after everyone has forgotten why it is there.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.