JSON Hypermedia: HAL, JSON:API Links, HATEOAS & Self-Describing APIs

Last updated:

Hypermedia for JSON APIs means embedding navigation links directly in API responses so clients can discover available actions and resources at runtime — without consulting documentation or hardcoding URLs. This is the core idea behind HATEOAS (Hypermedia as the Engine of Application State), the REST constraint that elevates an API to Richardson Maturity Model Level 3. A Level 3 response for an order resource includes not just the order data but also links to self, pay, cancel, customer — generated conditionally based on the order's current state. A shipped order has no pay link; a cancelled order has no cancel link. Four JSON hypermedia formats have emerged for encoding these links: HAL (_links and _embedded), JSON:API (a links object within a structured document envelope), Siren (links plus typed actions with fields), and Collection+JSON (query templates for collection resources). This guide covers each format's concrete JSON structure, when to use each, how to implement _links in Express and Next.js, standard IANA link relation values for pagination, TypeScript types for link traversal, and honest trade-offs between hypermedia and simpler Level 2 REST.

What Hypermedia-Driven APIs Mean (Richardson Maturity Level 3)

Roy Fielding's 2000 dissertation defined REST as an architectural style — not a protocol — with six constraints. One of them, the "uniform interface" constraint, includes the requirement that representations include hypermedia controls (links). Almost no API calling itself "REST" actually satisfies this. Leonard Richardson's Maturity Model gives a more practical vocabulary for describing how far an API has travelled toward true REST.

Level 0 is the RPC tunnel — one endpoint, usually POST, with action-specific request bodies (think SOAP or old-style XML-RPC). Level 1 introduces resources as distinct URL paths (/orders, /customers) but still uses one HTTP method for everything. Level 2 — where most APIs live — uses resources plus correct HTTP semantics: GET to read, POST to create, PUT/PATCH to update, DELETE to remove, with meaningful status codes. Level 3 (hypermedia) adds navigation: responses include links to related resources and valid next actions, enabling client-side traversal without out-of-band URL knowledge.

// Level 2 response — data only, client must know all URLs
GET /orders/1001
{
  "id": 1001,
  "status": "pending",
  "amount": 149.99,
  "customerId": 42
}

// Level 3 response — same data plus hypermedia controls
GET /orders/1001
{
  "id": 1001,
  "status": "pending",
  "amount": 149.99,
  "customerId": 42,
  "_links": {
    "self":     { "href": "https://api.example.com/orders/1001" },
    "customer": { "href": "https://api.example.com/customers/42" },
    "pay":      { "href": "https://api.example.com/orders/1001/payment" },
    "cancel":   { "href": "https://api.example.com/orders/1001/cancel" }
  }
}
// Note: once the order is "shipped", the response will omit "pay"
// and "cancel" links — the state machine is encoded in the response itself

The practical benefit is decoupling: when the server restructures URLs, clients that follow links rather than hardcode paths receive the new URLs automatically. Clients discover new capabilities by recognising new link rels they previously ignored. This makes hypermedia particularly valuable for public APIs with long-lived third-party clients. For API versioning strategies that complement hypermedia, see the linked guide.

HAL (Hypertext Application Language) Format

HAL is the most widely adopted JSON hypermedia format. Defined as an IETF Internet Draft (draft-kelly-json-hal), it adds two reserved properties to any JSON object: _links for navigation and _embedded for inlined sub-resources. Everything else in the object is treated as regular data — making HAL easy to retrofit onto an existing API without restructuring the response envelope. The media type is application/hal+json.

// Full HAL response for an order resource
// Content-Type: application/hal+json
{
  "id": 1001,
  "status": "paid",
  "amount": 149.99,
  "createdAt": "2026-01-31T10:00:00Z",

  // _links: navigation controls
  "_links": {
    "self": {
      "href": "https://api.example.com/orders/1001"
    },
    "collection": {
      "href": "https://api.example.com/orders",
      "title": "All orders"
    },
    "customer": {
      "href": "https://api.example.com/customers/42"
    },
    "items": {
      "href": "https://api.example.com/orders/1001/items"
    },
    "invoice": {
      "href": "https://api.example.com/orders/1001/invoice",
      "type": "application/pdf"
    },
    // href-template with variables (RFC 6570 URI Template)
    "order:item": {
      "href": "https://api.example.com/orders/1001/items/{itemId}",
      "templated": true
    },
    // curies: compact URI expressions — namespace for custom rels
    "curies": [
      {
        "name": "order",
        "href": "https://api.example.com/rels/{rel}",
        "templated": true
      }
    ]
  },

  // _embedded: sub-resources inlined to avoid extra round-trips
  "_embedded": {
    "items": [
      {
        "id": 501,
        "product": "Widget Pro",
        "qty": 2,
        "price": 49.99,
        "_links": {
          "self": { "href": "https://api.example.com/orders/1001/items/501" }
        }
      },
      {
        "id": 502,
        "product": "Gadget Plus",
        "qty": 1,
        "price": 50.01,
        "_links": {
          "self": { "href": "https://api.example.com/orders/1001/items/502" }
        }
      }
    ]
  }
}

// HAL collection response with pagination links
// GET /orders?page=2&limit=20
{
  "total": 247,
  "page": 2,
  "limit": 20,
  "_links": {
    "self":  { "href": "/orders?page=2&limit=20" },
    "first": { "href": "/orders?page=1&limit=20" },
    "prev":  { "href": "/orders?page=1&limit=20" },
    "next":  { "href": "/orders?page=3&limit=20" },
    "last":  { "href": "/orders?page=13&limit=20" }
  },
  "_embedded": {
    "orders": [
      {
        "id": 1020,
        "status": "paid",
        "amount": 89.99,
        "_links": { "self": { "href": "/orders/1020" } }
      }
    ]
  }
}

Curies (Compact URI Expressions) let you namespace custom link relations: instead of the full URL https://api.example.com/rels/shipment as a rel, you write order:shipment and declare the order curie expansion. This keeps link keys readable while maintaining globally unique relation names. HAL is adopted by Spring HATEOAS (Java), AWS API Gateway, PayPal REST APIs, and many Node.js libraries including halson and hal-json-serializer. See the JSON API design guide for broader response structure patterns.

JSON:API Links Format

JSON:API (jsonapi.org) is a full specification — not just a links convention — covering resource structure, relationships, pagination, sparse fieldsets, filtering, and error format. Its links support is embedded within a standardised top-level document structure. The required media type is exactly application/vnd.api+json: the server must reject requests with an Accept: application/vnd.api+json; charset=utf-8 header (parameters are not allowed).

// JSON:API single resource response
// Content-Type: application/vnd.api+json
{
  "data": {
    "type": "orders",
    "id": "1001",
    "attributes": {
      "status": "paid",
      "amount": 149.99,
      "createdAt": "2026-01-31T10:00:00Z"
    },
    // relationships with links
    "relationships": {
      "customer": {
        "links": {
          "self":    "/orders/1001/relationships/customer",
          "related": "/orders/1001/customer"
        },
        "data": { "type": "customers", "id": "42" }
      },
      "items": {
        "links": {
          "self":    "/orders/1001/relationships/items",
          "related": "/orders/1001/items"
        }
      }
    },
    // resource-level links
    "links": {
      "self": "/orders/1001"
    }
  },
  // top-level links
  "links": {
    "self": "/orders/1001"
  },
  // included: sideloaded related resources (avoids N+1 requests)
  "included": [
    {
      "type": "customers",
      "id": "42",
      "attributes": {
        "name": "Alice",
        "email": "alice@example.com"
      },
      "links": { "self": "/customers/42" }
    }
  ]
}

// JSON:API collection response with pagination links
// GET /orders?page[number]=2&page[size]=20
{
  "data": [
    {
      "type": "orders",
      "id": "1020",
      "attributes": { "status": "paid", "amount": 89.99 },
      "links": { "self": "/orders/1020" }
    }
  ],
  "links": {
    "self":  "/orders?page[number]=2&page[size]=20",
    "first": "/orders?page[number]=1&page[size]=20",
    "prev":  "/orders?page[number]=1&page[size]=20",
    "next":  "/orders?page[number]=3&page[size]=20",
    "last":  "/orders?page[number]=13&page[size]=20"
  },
  "meta": {
    "total": 247
  }
}

// JSON:API error response format
{
  "errors": [
    {
      "id": "e-001",
      "status": "422",
      "code": "VALIDATION_FAILED",
      "title": "Invalid attribute",
      "detail": "Amount must be greater than 0",
      "source": { "pointer": "/data/attributes/amount" },
      "links": {
        "about": "/docs/errors/VALIDATION_FAILED"
      }
    }
  ]
}

JSON:API's structured document envelope is more opinionated than HAL — you cannot simply add it to an existing API without restructuring responses to use data, attributes, and relationships. This is intentional: the specification aims to eliminate bikeshedding by defining the complete contract. Strong tooling exists for Rails (jsonapi-resources), Ember.js (built-in adapter), and JavaScript (json-api-serializer, normalizr). For JSON API error handling using the JSON:API error format, see the linked guide.

Siren and Collection+JSON Formats

Two less-common but conceptually important hypermedia formats address gaps that HAL and JSON:API leave: Siren adds typed actions with input fields (describing write operations, not just navigational links), and Collection+JSON provides a query template mechanism for parameterised collection searches.

// ── Siren format ────────────────────────────────────────────────────
// Siren adds: class, properties, actions, entities (sub-entities), links
// Content-Type: application/vnd.siren+json
{
  "class": ["order"],
  "properties": {
    "id": 1001,
    "status": "pending",
    "amount": 149.99
  },
  // links: navigational (same-concept as HAL _links)
  "links": [
    { "rel": ["self"],       "href": "/orders/1001" },
    { "rel": ["collection"], "href": "/orders" },
    { "rel": ["customer"],   "href": "/customers/42" }
  ],
  // actions: describe write operations with typed input fields
  // — this is what HAL/JSON:API lack: machine-readable form definitions
  "actions": [
    {
      "name": "pay-order",
      "title": "Pay this order",
      "method": "POST",
      "href": "/orders/1001/payment",
      "type": "application/json",
      "fields": [
        { "name": "paymentMethod", "type": "text",   "value": "card" },
        { "name": "cardToken",     "type": "text"                    },
        { "name": "saveCard",      "type": "checkbox", "value": false }
      ]
    },
    {
      "name": "cancel-order",
      "title": "Cancel this order",
      "method": "DELETE",
      "href": "/orders/1001",
      "fields": [
        { "name": "reason", "type": "text" }
      ]
    }
  ],
  // entities: embedded sub-entities (like HAL _embedded)
  "entities": [
    {
      "class": ["item"],
      "rel":   ["items"],
      "href":  "/orders/1001/items/501",
      "properties": {
        "product": "Widget Pro",
        "qty": 2,
        "price": 49.99
      },
      "links": [
        { "rel": ["self"], "href": "/orders/1001/items/501" }
      ]
    }
  ]
}

// ── Collection+JSON format ───────────────────────────────────────────
// Designed for collection resources; standardises query templates
// Content-Type: application/vnd.collection+json
{
  "collection": {
    "version": "1.0",
    "href": "/orders",
    "links": [
      { "rel": "profile", "href": "/profiles/order" }
    ],
    // items: the actual collection members
    "items": [
      {
        "href": "/orders/1001",
        "data": [
          { "name": "id",     "value": 1001,      "prompt": "Order ID" },
          { "name": "status", "value": "paid",    "prompt": "Status"   },
          { "name": "amount", "value": 149.99,    "prompt": "Amount"   }
        ],
        "links": [
          { "rel": "customer", "href": "/customers/42" }
        ]
      }
    ],
    // queries: machine-readable search/filter parameters
    "queries": [
      {
        "rel":    "search",
        "href":   "/orders",
        "prompt": "Search orders",
        "data": [
          { "name": "status", "value": "",       "prompt": "Status filter" },
          { "name": "from",   "value": "",       "prompt": "From date"     },
          { "name": "to",     "value": "",       "prompt": "To date"       },
          { "name": "limit",  "value": "20",     "prompt": "Page size"     }
        ]
      }
    ],
    // template: form for creating a new item in the collection
    "template": {
      "data": [
        { "name": "status", "value": "pending", "prompt": "Initial status" },
        { "name": "amount", "value": "",        "prompt": "Order amount"   },
        { "name": "customerId", "value": "",    "prompt": "Customer ID"    }
      ]
    }
  }
}

Siren excels in scenarios where clients need to render UI forms from API metadata alone — no front-end code hardcodes form fields or HTTP methods. This is valuable for generic API browsers and low-code tooling. Collection+JSON is purpose-built for list resources with search capabilities: the queries template tells a client what parameters the collection accepts, making it self-describing. Use Siren when write operations (actions) need to be machine-discoverable. Use Collection+JSON when your primary resource type is a searchable, paginated collection.

Designing HATEOAS Links in Practice

Good HATEOAS link design requires three decisions: which rels to use (standard IANA or custom), whether to use URI templates for parameterised links, and how to generate links conditionally based on resource state. Using IANA standard rel values wherever they apply is critical — it allows generic clients and link-aware middleware to handle common patterns (pagination, self-referential links) without API-specific configuration.

// ── IANA standard rel values to use ─────────────────────────────────
// self         — canonical URL of this resource
// collection   — collection this resource belongs to
// item         — an item within a collection
// related      — related resource (generic)
// next / prev / first / last — pagination
// edit         — URL to modify this resource
// describedby  — URL of JSON Schema describing this document
// up           — parent resource (breadcrumb)
// alternate    — alternative representation (e.g., PDF version)
// profile      — URL describing the semantics of this resource

// ── HAL _links with URI template (RFC 6570) ───────────────────────────
{
  "_links": {
    "self": {
      "href": "/orders/1001"
    },
    // static links — always present
    "collection": { "href": "/orders" },
    "customer":   { "href": "/customers/42" },
    "describedby": {
      "href": "/schemas/order",
      "type": "application/schema+json"
    },
    // URI template — client substitutes {itemId} to construct item URLs
    "item": {
      "href": "/orders/1001/items/{itemId}",
      "templated": true
    },
    // conditional links — only present when action is valid
    // shown when status === "pending"
    "pay":    { "href": "/orders/1001/payment", "title": "Pay this order" },
    "cancel": { "href": "/orders/1001/cancel",  "title": "Cancel order"  }
    // "pay" and "cancel" are absent when status === "shipped"
  }
}

// ── Express middleware to generate HAL links ──────────────────────────
// hal.ts — reusable HAL link builder
type HalLink = { href: string; title?: string; templated?: boolean; type?: string }
type HalLinks = Record<string, HalLink | HalLink[]>

function buildOrderLinks(order: Order, baseUrl: string): HalLinks {
  const base = `${baseUrl}/orders/${order.id}`
  const links: HalLinks = {
    self:        { href: base },
    collection:  { href: `${baseUrl}/orders` },
    customer:    { href: `${baseUrl}/customers/${order.customerId}` },
    item:        { href: `${base}/items/{itemId}`, templated: true },
    describedby: { href: `${baseUrl}/schemas/order`, type: 'application/schema+json' },
  }
  // state-conditional links
  if (order.status === 'pending') {
    links.pay    = { href: `${base}/payment`, title: 'Pay this order' }
    links.cancel = { href: `${base}/cancel` }
  }
  if (order.status === 'paid') {
    links.cancel  = { href: `${base}/cancel` }
    links.invoice = { href: `${base}/invoice`, type: 'application/pdf' }
  }
  if (order.status === 'shipped') {
    links.tracking = { href: `${base}/tracking` }
  }
  return links
}

// Express route handler
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(Number(req.params.id))
  if (!order) return res.status(404).json({ error: 'Not found' })

  const baseUrl = `https://${req.get('host')}/api`
  res.setHeader('Content-Type', 'application/hal+json')
  res.json({
    id:         order.id,
    status:     order.status,
    amount:     order.amount,
    createdAt:  order.createdAt,
    _links:     buildOrderLinks(order, baseUrl),
  })
})

Generating baseUrl dynamically from the request host (rather than hardcoding it) ensures links work correctly in development, staging, and production without environment-specific configuration. The IANA Link Relations Registry at https://www.iana.org/assignments/link-relations/ is the canonical reference — check it before inventing a custom rel. For broader REST API design patterns, see the linked guide.

Client-Side Consumption of Hypermedia APIs

A true HATEOAS client starts from a single known root URL, follows links to discover all other URLs, and never constructs URLs by string concatenation. In practice, most clients adopt a hybrid approach: they know a few stable base URLs from documentation but use links for navigation within a resource graph. The key TypeScript patterns for both approaches are shown below.

// ── TypeScript types for HAL responses ───────────────────────────────
interface HalLink {
  href:       string
  templated?: boolean
  title?:     string
  type?:      string
}

interface HalResponse {
  _links:    Record<string, HalLink | HalLink[]>
  _embedded?: Record<string, HalResponse | HalResponse[]>
  [key: string]: unknown
}

interface HalOrder extends HalResponse {
  id:        number
  status:    'pending' | 'paid' | 'shipped' | 'cancelled'
  amount:    number
  createdAt: string
}

// ── Link traversal helpers ────────────────────────────────────────────
function getLink(resource: HalResponse, rel: string): HalLink | undefined {
  const link = resource._links[rel]
  if (!link) return undefined
  return Array.isArray(link) ? link[0] : link
}

function expandTemplate(template: string, vars: Record<string, string>): string {
  return template.replace(/{(w+)}/g, (_, key) => vars[key] ?? '')
}

// ── HAL client: follow links without hardcoded URLs ───────────────────
const API_ROOT = 'https://api.example.com'

async function fetchHal<T extends HalResponse>(url: string): Promise<T> {
  const res = await fetch(url, {
    headers: { Accept: 'application/hal+json' },
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`)
  return res.json() as Promise<T>
}

// Start from root, discover orders collection URL
async function getOrders(): Promise<HalOrder[]> {
  const root = await fetchHal<HalResponse>(API_ROOT)
  const ordersLink = getLink(root, 'orders')
  if (!ordersLink) throw new Error('No orders link in API root')

  const collection = await fetchHal<HalResponse>(ordersLink.href)
  const embedded = collection._embedded?.orders
  return (Array.isArray(embedded) ? embedded : []) as HalOrder[]
}

// Follow a single order link and conditionally trigger payment
async function payOrder(order: HalOrder): Promise<void> {
  const payLink = getLink(order, 'pay')
  if (!payLink) {
    console.warn('Order cannot be paid in its current state:', order.status)
    return
  }
  await fetch(payLink.href, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymentMethod: 'card', cardToken: 'tok_xxx' }),
  })
}

// ── Paginate through a HAL collection ────────────────────────────────
async function* paginateOrders(): AsyncGenerator<HalOrder> {
  let url: string | undefined = `${API_ROOT}/orders`
  while (url) {
    const page = await fetchHal<HalResponse>(url)
    const items = page._embedded?.orders ?? []
    for (const item of Array.isArray(items) ? items : []) {
      yield item as HalOrder
    }
    const nextLink = getLink(page, 'next')
    url = nextLink?.href
  }
}

// Usage: stream through all orders without knowing total page count
async function processAllOrders() {
  for await (const order of paginateOrders()) {
    console.log(order.id, order.status)
  }
}

// ── React hook for link-driven navigation ────────────────────────────
import { useState, useEffect } from 'react'

function useHalResource<T extends HalResponse>(url: string | undefined) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (!url) return
    setLoading(true)
    fetchHal<T>(url)
      .then(setData)
      .finally(() => setLoading(false))
  }, [url])

  return { data, loading, follow: (rel: string) => getLink(data!, rel)?.href }
}

The paginateOrders async generator pattern is particularly clean for HAL pagination: it follows next links until none remain, handling arbitrary page counts without knowing the total upfront. The React hook exposes a follow(rel) helper that returns the href for a named link relation — the component passes this to a new useHalResource call to navigate the resource graph. For React Query patterns that integrate with this link traversal approach, see the linked guide.

When to Use Hypermedia vs Simple REST

The honest answer is that most APIs do not need hypermedia. Level 2 REST (resources + HTTP verbs) is sufficient for the vast majority of production use cases and is far simpler to build and consume. The trade-offs are real and worth understanding before committing to a hypermedia format.

// ── Decision matrix: hypermedia vs Level 2 REST ──────────────────────

// USE hypermedia (HAL or JSON:API) when:
// ✓ Public API consumed by unknown third-party clients over 3+ years
// ✓ Complex state machine where valid actions change per resource state
//   (payment workflows, multi-step approvals, document lifecycle)
// ✓ API URL structure is likely to change (internal restructuring,
//   moving to a different domain, microservice decomposition)
// ✓ Generic clients or API browsers need to explore the API without docs
// ✓ You already use Spring HATEOAS (Java) or rails-api/jsonapi-resources

// SKIP hypermedia (use Level 2) when:
// ✗ Internal API consumed only by your own frontend — URLs are stable
// ✗ Mobile app with short release cycle — you deploy client and server together
// ✗ GraphQL-style query patterns — graph APIs have different conventions
// ✗ Tight latency requirements — extra _links payload adds response size
// ✗ Small team, rapid iteration — hypermedia adds boilerplate complexity

// ── Real-world adoption ───────────────────────────────────────────────
// HAL:          Spring HATEOAS, AWS API Gateway, PayPal REST, Stripe (partial)
// JSON:API:     Ember.js apps, Drupal JSON:API, Rails jsonapi-resources
// Siren:        Rare in production — mostly academic/experimental
// Collection+J: Rare — mostly internal tooling and older .NET APIs
// Level 2 only: GitHub API, Twitter/X API, Google APIs, most SaaS APIs

// ── Hybrid approach: links without full HATEOAS ───────────────────────
// Many teams get 80% of the benefit with 20% of the complexity:
// 1. Add a "self" link to every resource response (canonical URL)
// 2. Add pagination links (first, prev, next, last) to collections
// 3. Add conditional "action" links for state-machine resources
// 4. Skip _embedded, curies, and full HAL conformance
//
// This "link-lite" approach improves client resilience without the
// full cognitive overhead of HAL or JSON:API compliance.
const orderResponse = {
  id: 1001,
  status: 'pending',
  amount: 149.99,
  // "link-lite" — not full HAL, but practical improvement over Level 2
  links: {
    self:   `/orders/1001`,
    pay:    '/orders/1001/payment',   // conditional — only when pending
    cancel: '/orders/1001/cancel',    // conditional — only when pending/paid
  },
}

The "link-lite" hybrid is the most pragmatic choice for teams that want better evolvability than Level 2 without committing to a full hypermedia specification. It adds a links object with self and action links, skips _embedded and curies, and does not claim application/hal+json conformance. Clients that want to follow links can; clients that prefer stable URL patterns can use those too. For JSON:API specification implementation details, see the linked guide.

Key Terms

HATEOAS (Hypermedia as the Engine of Application State)
A REST constraint coined by Roy Fielding stating that API responses must include hypermedia controls (links) describing available next actions, so clients do not need out-of-band URL knowledge. A HATEOAS-compliant client starts from a single root URL and navigates the entire API by following links embedded in responses. The practical effect is URL decoupling: clients do not hardcode paths, so the server can restructure URLs without breaking clients that follow links. HATEOAS is the defining characteristic of Richardson Maturity Model Level 3. Most production APIs calling themselves "REST" are at Level 2 and do not implement HATEOAS — this is a deliberate trade-off, not a deficiency. JSON hypermedia formats that enable HATEOAS in JSON APIs include HAL, JSON:API, Siren, and Collection+JSON.
HAL (Hypertext Application Language)
A minimal JSON hypermedia format defined in IETF Internet Draft draft-kelly-json-hal. HAL adds two reserved properties to any JSON object: _links (an object mapping link relation names to link objects with at minimum an href field) and _embedded (an object containing inlined sub-resources keyed by their link relation). All other properties in the object are treated as resource data. This design means HAL can be retrofitted onto an existing JSON API without restructuring the response envelope. The media type is application/hal+json. HAL supports URI templates (RFC 6570) via the templated: true flag on a link, and supports curies (compact URI expressions) for namespacing custom link relation types. HAL is adopted by Spring HATEOAS, AWS API Gateway, PayPal REST APIs, and many API gateway products.
Link Relation (rel)
A semantic identifier — a string — that describes the relationship between a resource and the target of a link. Standard rel values are registered in the IANA Link Relations Registry (https://www.iana.org/assignments/link-relations/) and include: self (canonical URL of this resource), next/prev/first/last (pagination), related (related resource), collection (collection this belongs to), item (item within a collection), edit (editable form), describedby (schema URL), up (parent resource), alternate (alternative representation). Custom rel values should be absolute URIs to avoid naming conflicts: https://api.example.com/rels/pay. HAL curies let you abbreviate these: ex:pay with a curie declaration expanding ex: to the base URI. Using IANA standard rels enables generic clients and middleware to handle common patterns automatically.
Embedded Resource
A sub-resource inlined inside a parent response to avoid additional HTTP round-trips. In HAL, embedded resources live in the _embedded object; in JSON:API, they live in the top-level included array. An embedded resource is a full resource representation — it has its own _links.self, its own data properties, and may have its own embedded sub-resources. The decision to embed a resource vs. providing only a link involves a trade-off: embedding reduces round-trips (good for performance) but increases response payload size and couples the parent and child representations. Embed resources when they are always needed together; link to them when they are sometimes needed. HAL allows both in the same response: a link in _links for clients who want to fetch separately, and the resource in _embedded for clients who want it inlined.
Richardson Maturity Model
A four-level framework described by Leonard Richardson for characterising how closely an HTTP API adheres to REST principles. Level 0 (The Swamp of POX): one endpoint, all operations via POST, RPC-style — SOAP and XML-RPC sit here. Level 1 (Resources): multiple endpoint URLs representing distinct resources, but uniform HTTP method use (usually POST or GET for everything). Level 2 (HTTP Verbs): resources plus semantically correct use of HTTP methods (GET to read, POST to create, PUT/PATCH to update, DELETE to remove) with meaningful status codes — where most "REST" APIs live. Level 3 (Hypermedia Controls): responses include HATEOAS links describing available next actions, making the API self-describing and navigable. The model is descriptive, not prescriptive — Level 2 is not "bad REST." Most production APIs are at Level 2; Level 3 is adopted when client-URL decoupling and long-term evolvability justify the added complexity.
curies (Compact URI Expressions)
A HAL mechanism (borrowed from RDFa) for abbreviating long custom link relation URIs. A curie declaration maps a short prefix to a URI template: "curies": [{"name": "order", "href": "https://api.example.com/rels/{rel}", "templated": true}]. With this declaration, a link relation of order:pay expands to https://api.example.com/rels/pay. This keeps link relation keys readable in the JSON while maintaining globally unique, dereferenceable relation identifiers. Clients that understand curies can follow the curie template to documentation at the expanded URI. Clients that do not understand curies should treat the full curied name (order:pay) as an opaque identifier. Curies are optional in HAL and are primarily useful for public APIs where globally unique custom relation names are important.
Self-Describing API
An API whose responses carry enough metadata for a client to understand the available resources, actions, and navigation paths without consulting out-of-band documentation. A fully self-describing API combines hypermedia links (HAL _links, JSON:API links) with machine-readable action descriptions (Siren actions), schema references (describedby links pointing to JSON Schema), and human-readable titles on links and actions. In practice, "self-describing" is a spectrum: adding self and pagination links is a modest step toward self-description; a full Siren API with typed action fields and schema links approaches full self-description. Self-describing APIs are most valuable when the client population is heterogeneous — generic API browsers, third-party integrators, and automated tooling all benefit from machine-readable metadata. Internal APIs with a single known client gain little from the added complexity.

FAQ

What is HATEOAS and do I need it in my JSON API?

HATEOAS (Hypermedia as the Engine of Application State) means embedding navigation links in API responses so clients discover available actions at runtime rather than hardcoding URLs. A HATEOAS order response includes links to pay, cancel, and customer — generated conditionally based on the order's current state. A shipped order has no pay link; this state machine is encoded in the response itself. Whether you need it depends on your audience and lifespan: HATEOAS provides the most value for public APIs with long-lived third-party clients, complex state machines, or URLs likely to change. For internal APIs, mobile apps with short release cycles, or single-page apps where client and server are co-deployed, Level 2 REST (resources + HTTP verbs) is sufficient and simpler. Most production APIs — including GitHub, Stripe, and Google APIs — are at Level 2 and work well. Treat Level 3 as a deliberate architectural choice for evolvability, not a requirement for calling your API "REST."

What is the difference between HAL and JSON:API link formats?

HAL adds only two reserved properties — _links and _embedded — to your existing JSON envelope. It is minimal and easy to retrofit. The media type is application/hal+json. JSON:API is a complete specification covering document structure (data, attributes, relationships, included, meta), links, pagination, sparse fieldsets, filtering conventions, and error format. Its media type is application/vnd.api+json — strictly no charset parameter. HAL is more flexible (links can be anything); JSON:API is more opinionated (responses must conform to the document structure). HAL is adopted by Spring HATEOAS, AWS API Gateway, and PayPal. JSON:API has strong tooling in Ember.js and Rails. Choose HAL to add hypermedia to an existing API with minimal restructuring. Choose JSON:API when you want a complete client-server contract eliminating per-team bikeshedding about document shape.

How do I implement _links in a HAL JSON response with Express or Next.js?

Build a HAL envelope helper and generate links conditionally based on resource state. In Express: define a buildOrderLinks(order, baseUrl) function that returns a _links object — include pay and cancel links only when order.status === 'pending'. Set Content-Type: application/hal+json on the response. Generate baseUrl from req.get('host') so links work across environments. In Next.js App Router route handlers, the same logic applies in a route.ts file: return Response.json(halEnvelope, { headers: { 'Content-Type': 'application/hal+json' } });. The critical implementation detail is conditional link generation — a HATEOAS API that always returns all links regardless of state provides no state-machine benefit over a plain Level 2 API. Links should appear and disappear as resource state changes, making valid transitions explicit in the response.

What are the standard IANA link relation types for pagination (next, prev, first, last)?

The IANA Link Relations Registry defines standard pagination rels: first (first page), last (last page), next (next page — omit on the last page), prev (previous page — omit on the first page), and self (current page URL). In HAL: {"_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=13"}}}. In JSON:API the same rels appear in the top-level links object. Other useful IANA relations: self (canonical URL), related (generic related resource), collection (collection this item belongs to), item (item in a collection), describedby (JSON Schema URL), edit (editable form URL), up (parent in a hierarchy), alternate (alternative representation). Using IANA standard rels enables generic HAL/JSON:API client libraries to handle pagination automatically without API-specific configuration.

Does HATEOAS make JSON APIs harder to consume from a frontend?

Yes, honestly. HATEOAS adds friction for frontend developers accustomed to hardcoding API URLs from documentation. A proper HATEOAS client must start from a root URL and follow links to navigate — more round-trips, more code, harder to debug. The pragmatic middle ground: embed _links in responses but also document URL patterns in your API reference. Clients who want URL stability can use documented paths; clients who want resilience to URL changes can follow links. The genuine frontend benefit of hypermedia is not link traversal — it is using link presence as state metadata. A pay link appearing or disappearing based on order status is useful UI logic: show the "Pay Now" button if and only if response._links.pay exists. This eliminates front-end business logic that replicates server-side state rules. The worst outcome is implementing HAL or JSON:API for compliance optics without designing the client to follow links or use link presence — you get hypermedia complexity with none of the decoupling benefit.

What is the Richardson Maturity Model and where do most REST APIs sit?

The Richardson Maturity Model (RMM) is a four-level framework for how REST-ful an HTTP API is. Level 0: one endpoint, RPC-style, everything via POST (SOAP, XML-RPC). Level 1: distinct resource URLs (/orders, /customers) but same HTTP method for all operations. Level 2: resources plus semantically correct HTTP methods (GET to read, POST to create, PUT/PATCH to update, DELETE to remove) with meaningful status codes — this is what most developers mean by "REST API." Level 3: responses include HATEOAS links describing valid next actions. In practice, approximately 80-90% of production APIs calling themselves REST are at Level 2. GitHub, Stripe, Twilio, most Google APIs, and most SaaS APIs are Level 2. Level 3 is implemented by Spring HATEOAS Java services, AWS API Gateway HAL responses, and PayPal REST APIs. The RMM is descriptive, not prescriptive — Level 2 is not inferior REST. GraphQL is not on the RMM scale at all; it uses a different paradigm. Choose your level based on evolvability requirements, not on achieving a score.

How does hypermedia help with API versioning and backwards compatibility?

Hypermedia solves URL coupling — the specific versioning problem where clients break when server URLs change. With clients that follow links rather than hardcode paths, the server can change /api/v1/orders to /api/v2/orders and clients transparently receive the new URL in responses. This is the genuine long-term benefit of HATEOAS. The mechanism: clients always start from a stable root URL; that root returns _links for all top-level resources; when server URLs change, the root links update and old paths redirect. Adding a new link relation (refund, dispute) to responses does not break existing clients — they ignore unknown rels. Removing a link (e.g., cancel disappears when an order ships) communicates state transitions without documentation. The limitation: hypermedia cannot help with breaking changes to the data schema itself — renaming amount to totalPrice breaks clients regardless of link format. For schema changes, traditional versioning strategies (URL versioning, Accept header versioning, field-level deprecation notices) are still required. Hypermedia and URL versioning are complementary, not alternatives.

Further reading and primary sources