Skip to content
Codeloom
REST APIs

REST API Hypermedia & HATEOAS: HAL, JSON:API & Practical Examples

Implement HATEOAS in your REST API using HAL, JSON:API, and custom link formats. Learn when hypermedia helps, when it hurts, and how to add it incrementally.

·8 min read · By Codeloom
Advanced 12 min read

What you'll learn

  • What HATEOAS means and why Roy Fielding considers it essential to REST
  • How HAL (Hypertext Application Language) structures links and embedded resources
  • How JSON:API standardizes resource relationships and includes
  • Practical patterns for adding hypermedia links to your API
  • When HATEOAS pays off and when it adds unnecessary complexity

Prerequisites

  • Solid understanding of REST API design
  • Familiarity with HTTP methods and status codes

HATEOAS — Hypermedia as the Engine of Application State — is the most debated constraint in REST. Roy Fielding has said that an API that does not use hypermedia is not truly RESTful. Most production APIs ignore it entirely. The truth, as usual, is somewhere in between. This article explains what HATEOAS actually is, shows you three formats for implementing it, and helps you decide where it adds value.

What is HATEOAS?

In a traditional API, clients hardcode URLs:

// Client knows the URL structure
const user = await fetch('/api/users/123');
const orders = await fetch('/api/users/123/orders');
const order = await fetch('/api/orders/456');
await fetch('/api/orders/456/cancel', { method: 'POST' });

With HATEOAS, the server tells the client what it can do next by including links in every response:

{
  "id": "ord_456",
  "status": "pending",
  "total": 89.99,
  "links": {
    "self":    { "href": "/api/orders/ord_456" },
    "cancel":  { "href": "/api/orders/ord_456/cancel", "method": "POST" },
    "payment": { "href": "/api/orders/ord_456/payment", "method": "PUT" },
    "customer": { "href": "/api/users/usr_123" }
  }
}

The client discovers available actions from the response instead of constructing URLs itself. If the order is already shipped, the cancel link disappears — the server controls what transitions are possible.

Why it matters

  1. Decoupling — clients do not break when you change URL structures
  2. Discoverability — new features appear as new links; clients can adapt
  3. State machines — the available links encode the resource’s current state
  4. Evolvability — you can version, move, or rename endpoints without breaking clients

Format 1: HAL (Hypertext Application Language)

HAL is the most popular hypermedia format. It uses _links for related URLs and _embedded for included resources.

Content type: application/hal+json

Single resource

{
  "_links": {
    "self": { "href": "/api/orders/ord_456" },
    "customer": { "href": "/api/users/usr_123" },
    "cancel": { "href": "/api/orders/ord_456/cancel" },
    "items": { "href": "/api/orders/ord_456/items" }
  },
  "id": "ord_456",
  "status": "pending",
  "total": 89.99,
  "currency": "USD",
  "created_at": "2026-07-08T10:00:00Z"
}

Collection with embedded resources

{
  "_links": {
    "self": { "href": "/api/orders?page=2" },
    "first": { "href": "/api/orders?page=1" },
    "prev": { "href": "/api/orders?page=1" },
    "next": { "href": "/api/orders?page=3" },
    "last": { "href": "/api/orders?page=10" }
  },
  "_embedded": {
    "orders": [
      {
        "_links": {
          "self": { "href": "/api/orders/ord_456" },
          "customer": { "href": "/api/users/usr_123" }
        },
        "id": "ord_456",
        "status": "pending",
        "total": 89.99
      },
      {
        "_links": {
          "self": { "href": "/api/orders/ord_789" },
          "customer": { "href": "/api/users/usr_456" }
        },
        "id": "ord_789",
        "status": "shipped",
        "total": 149.50
      }
    ]
  },
  "total_count": 98,
  "page": 2,
  "page_size": 10
}

HAL implementation in Express

function halResponse(res, resource, links, embedded = {}) {
  const body = {
    _links: {},
    ...resource,
  };

  // Convert links to HAL format
  for (const [rel, value] of Object.entries(links)) {
    if (typeof value === 'string') {
      body._links[rel] = { href: value };
    } else {
      body._links[rel] = value;
    }
  }

  if (Object.keys(embedded).length > 0) {
    body._embedded = embedded;
  }

  return res.type('application/hal+json').json(body);
}

// Usage
app.get('/api/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  if (!order) return res.status(404).json({ title: 'Not found' });

  const links = {
    self: `/api/orders/${order.id}`,
    customer: `/api/users/${order.customer_id}`,
    items: `/api/orders/${order.id}/items`,
  };

  // Conditionally add action links based on state
  if (order.status === 'pending') {
    links.cancel = { href: `/api/orders/${order.id}/cancel`, method: 'POST' };
    links.payment = { href: `/api/orders/${order.id}/payment`, method: 'PUT' };
  }
  if (order.status === 'paid') {
    links.ship = { href: `/api/orders/${order.id}/ship`, method: 'POST' };
    links.refund = { href: `/api/orders/${order.id}/refund`, method: 'POST' };
  }

  halResponse(res, {
    id: order.id,
    status: order.status,
    total: order.total,
    currency: order.currency,
    created_at: order.created_at,
  }, links);
});

Format 2: JSON:API

JSON:API is a more opinionated specification that standardizes resource structure, relationships, and included resources.

Content type: application/vnd.api+json

Single resource

{
  "data": {
    "type": "orders",
    "id": "ord_456",
    "attributes": {
      "status": "pending",
      "total": 89.99,
      "currency": "USD",
      "created_at": "2026-07-08T10:00:00Z"
    },
    "relationships": {
      "customer": {
        "data": { "type": "users", "id": "usr_123" },
        "links": {
          "related": "/api/users/usr_123"
        }
      },
      "items": {
        "links": {
          "related": "/api/orders/ord_456/items"
        }
      }
    },
    "links": {
      "self": "/api/orders/ord_456"
    }
  },
  "included": [
    {
      "type": "users",
      "id": "usr_123",
      "attributes": {
        "name": "Jane Smith",
        "email": "jane@example.com"
      },
      "links": {
        "self": "/api/users/usr_123"
      }
    }
  ]
}

Collection

{
  "data": [
    {
      "type": "orders",
      "id": "ord_456",
      "attributes": {
        "status": "pending",
        "total": 89.99
      },
      "links": {
        "self": "/api/orders/ord_456"
      }
    }
  ],
  "links": {
    "self": "/api/orders?page[number]=2&page[size]=10",
    "first": "/api/orders?page[number]=1&page[size]=10",
    "prev": "/api/orders?page[number]=1&page[size]=10",
    "next": "/api/orders?page[number]=3&page[size]=10",
    "last": "/api/orders?page[number]=10&page[size]=10"
  },
  "meta": {
    "total_count": 98,
    "page_count": 10
  }
}

Sparse fieldsets and includes

JSON:API supports query parameters for controlling what is returned:

GET /api/orders?include=customer,items&fields[orders]=status,total&fields[users]=name

This reduces payload size by only returning requested fields and relationships.

If HAL and JSON:API feel too heavy, add a simple links object to your existing response format:

{
  "data": {
    "id": "ord_456",
    "status": "pending",
    "total": 89.99
  },
  "links": {
    "self": "https://api.example.com/orders/ord_456",
    "cancel": "https://api.example.com/orders/ord_456/cancel",
    "customer": "https://api.example.com/users/usr_123"
  }
}

This is the most pragmatic approach for teams that want some hypermedia benefits without adopting a full specification.

Implementation helper

function addLinks(req, resource, linkDefs) {
  const baseUrl = `${req.protocol}://${req.get('host')}`;
  const links = {};

  for (const [rel, path] of Object.entries(linkDefs)) {
    if (path !== null) {
      links[rel] = `${baseUrl}${path}`;
    }
  }

  return { data: resource, links };
}

app.get('/api/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id);

  const response = addLinks(req, order, {
    self: `/api/orders/${order.id}`,
    customer: `/api/users/${order.customer_id}`,
    cancel: order.status === 'pending' ? `/api/orders/${order.id}/cancel` : null,
    ship: order.status === 'paid' ? `/api/orders/${order.id}/ship` : null,
  });

  res.json(response);
});

The most valuable aspect of HATEOAS is encoding state transitions. Consider an order that moves through states:

pending → paid → shipped → delivered
              ↘ cancelled
         ↘ refunded

Each state exposes different available actions:

const ORDER_LINKS = {
  pending: (id) => ({
    pay:    `/api/orders/${id}/pay`,
    cancel: `/api/orders/${id}/cancel`,
  }),
  paid: (id) => ({
    ship:   `/api/orders/${id}/ship`,
    refund: `/api/orders/${id}/refund`,
  }),
  shipped: (id) => ({
    deliver: `/api/orders/${id}/deliver`,
    track:   `/api/orders/${id}/tracking`,
  }),
  delivered: (id) => ({
    return: `/api/orders/${id}/return`,
    review: `/api/orders/${id}/review`,
  }),
  cancelled: () => ({}),
  refunded: () => ({}),
};

function getOrderLinks(order) {
  const stateLinks = ORDER_LINKS[order.status]?.(order.id) || {};
  return {
    self: `/api/orders/${order.id}`,
    ...stateLinks,
  };
}

The client never needs to know which actions are valid for which state — it just checks which links are present in the response.

When HATEOAS pays off

Worth it:

  • Public APIs consumed by many third-party developers
  • APIs with complex state machines (orders, workflows, approvals)
  • APIs that need to evolve without breaking clients
  • Pagination links (universally useful)

Not worth it:

  • Internal APIs with a single frontend consumer
  • Simple CRUD with no state transitions
  • High-performance APIs where every byte of payload matters
  • Teams without the tooling to consume hypermedia links

Incremental adoption

You do not need to go full HATEOAS overnight. Start with these high-value additions:

  1. Pagination linksself, next, prev, first, last
  2. Self links — every resource includes its own URL
  3. Related resource links — replace IDs with URLs clients can follow
  4. Action links — state-dependent actions appear as links
// Before: client must construct URLs
{ "customer_id": "usr_123" }

// After: client follows the link
{ "customer_id": "usr_123", "links": { "customer": "/api/users/usr_123" } }

Comparing the formats

FeatureSimple linksHALJSON:API
Spec complexityLowMediumHigh
Embedded resourcesNoYesYes (included)
Sparse fieldsetsNoNoYes
Relationship metadataNoMinimalRich
Community toolingLimitedGoodExtensive
Learning curveNoneLowModerate

Summary

HATEOAS is not all-or-nothing. Start by adding pagination links and self links to every response — these are universally useful and cost almost nothing. If your API has complex state machines, add state-driven action links so clients discover available transitions from the response. Choose HAL for a lightweight standard, JSON:API when you need sparse fieldsets and rich relationship handling, or simple custom links when you want maximum control. The goal is not purity — it is giving clients enough information to navigate your API without hardcoding every URL.