JSON:API vs HAL vs Plain JSON Contracts

This comparison belongs to Response Envelopes & Metadata, part of the Query Patterns & Data Shaping Strategies reference. Once you accept that collections need an envelope, the next question is whether to design your own or adopt a specified one. This page shows the same response in all three shapes and weighs what each buys.

The decision trigger

Situation Points toward
Many client apps, many interrelated resource types JSON:API
Clients should discover navigation rather than build URLs HAL
Two known clients and a generated SDK plain envelope
Sparse fieldsets and included resources are core requirements JSON:API
You need the smallest possible client-side parsing layer plain envelope
An ecosystem already standardises on a format follow it

The same response, three ways

// Plain envelope
{
  "data": [
    { "id": "inv_1", "customer_id": "cus_7", "total_minor": 4200, "currency": "GBP" }
  ],
  "meta": { "has_more": true, "next_cursor": "eyJpZCI6Imludl8xIn0" }
}
// JSON:API — application/vnd.api+json
{
  "data": [
    {
      "type": "invoices",
      "id": "inv_1",
      "attributes": { "total_minor": 4200, "currency": "GBP" },
      "relationships": {
        "customer": { "data": { "type": "customers", "id": "cus_7" } }
      },
      "links": { "self": "/invoices/inv_1" }
    }
  ],
  "included": [
    { "type": "customers", "id": "cus_7", "attributes": { "name": "Acme Ltd" } }
  ],
  "links": { "next": "/invoices?page[cursor]=eyJpZCI6Imludl8xIn0" },
  "meta": { "has_more": true }
}
// HAL — application/hal+json
{
  "_embedded": {
    "invoices": [
      {
        "id": "inv_1",
        "total_minor": 4200,
        "currency": "GBP",
        "_links": {
          "self":     { "href": "/invoices/inv_1" },
          "customer": { "href": "/customers/cus_7" }
        }
      }
    ]
  },
  "_links": {
    "self": { "href": "/invoices" },
    "next": { "href": "/invoices?cursor=eyJpZCI6Imludl8xIn0" }
  }
}
Where each format puts fields, ids, relationships and links In a plain envelope fields sit directly on the item. In JSON:API they sit under attributes with type and id alongside, relationships in their own map and related records in an included array. In HAL fields sit on the item with links under an underscore links map and collections under underscore embedded. Plain envelope JSON:API HAL data[] id, total_minor, … customer_id meta.next_cursor data[] type + id attributes{ … } relationships{ … } included[] links.next _embedded.invoices[] id, total_minor, … _links.customer _links.next flat items, ids for relations typed graph, compound documents flat items + link relations

Comparison

Dimension Plain envelope JSON:API HAL
Specification yours JSON:API 1.1 HAL draft + RFC 8288 relations
Media type application/json application/vnd.api+json application/hal+json
Item shape flat attributes + type/id flat
Relationships id fields relationships map _links
Related records inline you decide included[] compound doc _embedded
Sparse fieldsets your convention fields[type] specified not specified
Pagination your convention page[...] + links _links.next
Errors any (use Problem+JSON) errors[] specified unspecified
Client caching by identity manual strong (type+id) manual
Generated SDK quality good poor without a helper library fair
Payload verbosity low high medium

What each format actually buys

JSON:API buys a normalised client cache. Because every resource carries type and id, a generic client can maintain one store keyed by identity, deduplicate the same customer appearing in twenty invoices, and update every view when one record changes. That is a real architectural benefit for a rich front end — and it is nearly all of the value. If your clients do not maintain such a store, you are paying attributes-nesting on every field access for nothing.

HAL buys navigation without URL construction. Clients follow _links rather than building paths from templates, so a URL structure change does not break them. The catch is that this only helps clients that genuinely navigate; in practice most call one documented endpoint directly, and the _links block becomes bytes nobody reads. It also has no answer for sparse fieldsets or filtering, so you end up defining those yourself anyway.

A plain envelope buys codegen quality and simplicity. The generated type is { data: Invoice[], meta: PageMeta }, so invoice.total_minor works without an attributes hop, and every helper you write is about your domain rather than the format. What you give up is any pre-existing client library, and the discipline to define pagination, filtering and errors consistently — which is what the rest of this reference exists to supply.

Step-by-step: probing the codegen cost before choosing

# Generate a client from each candidate contract and read the call site.
for fmt in plain jsonapi hal; do
  npx @openapitools/openapi-generator-cli generate \
    -i "specs/invoices-$fmt.yaml" -g typescript-fetch -o "/tmp/fmt/$fmt" \
    --additional-properties=hideGenerationTimestamp=true >/dev/null
  echo "── $fmt ──"
  grep -A4 "export interface .*Invoice" "/tmp/fmt/$fmt/models/"*.ts | head -12
done

What you are looking for is how many hops a caller makes to read one field:

// plain
const total = page.data[0].total_minor;

// JSON:API, generic generator (no format-aware helper)
const total = page.data[0].attributes?.total_minor;
const customer = page.included?.find(
  r => r.type === "customers" && r.id === page.data[0].relationships?.customer?.data?.id,
);

// HAL, generic generator
const total = page._embedded?.invoices?.[0]?.total_minor;
const customerHref = page._embedded?.invoices?.[0]?._links?.customer?.href;

The JSON:API line is not an argument against the format — it is an argument for using a format-aware client library rather than a generic generator when you adopt it. The trade-off between generic and specialised tooling is the same one weighed in OpenAPI Generator vs Kiota vs Speakeasy.

Standard alignment

Element Standard Note
application/vnd.api+json JSON:API 1.1 Registered media type; strict conformance rules
application/hal+json HAL (I-D) Draft; _links/_embedded reserved
Link relation registry RFC 8288 §4 self, next, prev, first, last
Media type suffix +json RFC 6839 Structured syntax suffix
Error bodies RFC 9457 Preferred over format-specific error shapes

The last row is worth acting on regardless of envelope choice: JSON:API defines its own errors[] array, but application/problem+json is more widely understood, and mixing one error contract across an estate beats matching each endpoint’s success format. See Standardizing Error Responses Across Microservices.

The choice is easier once framed as what each format asks the client to build:

What each envelope asks of the client JSON:API expects a normalised store keyed by type and id. HAL expects link-following navigation. A plain envelope expects nothing beyond unwrapping the data member. pick the format whose assumption your client actually meets JSON:API a normalised entity cache worth it only if you build one HAL link-following navigation worth it only if clients navigate plain envelope unwrap data, read meta no assumption at all any of them Problem+JSON for errors never the format's error shape adopting a format whose assumption you do not meet buys the cost without the benefit

Borrowing without adopting

The pragmatic middle path is a plain envelope that borrows the link relation vocabulary without the rest of the machinery:

{
  "data": [ { "id": "inv_1", "customer_id": "cus_7", "total_minor": 4200 } ],
  "meta": { "has_more": true },
  "links": {
    "self": "/invoices?cursor=",
    "next": "/invoices?cursor=eyJpZCI6Imludl8xIn0"
  }
}

self and next are registered RFC 8288 relations, so their meaning is standard even though the container is yours. Clients that want to follow links can; clients that build URLs still can. What you should not do is emit _links and advertise application/hal+json unless the document genuinely conforms — a half-HAL response fails every HAL client while gaining nothing.

Choosing an envelope format First ask whether clients maintain a normalised cache across many resource types; if so choose JSON:API. Otherwise ask whether clients must discover navigation rather than construct URLs; if so borrow link relations or choose HAL. Otherwise choose a plain envelope. clients keep a normalised cross-type cache? yes JSON:API use a format-aware client no clients must discover navigation? yes HAL or borrowed links RFC 8288 relations no plain envelope best generated types whichever you choose, errors stay Problem+JSON across the whole estate

Common mistakes

Mistake Correct approach
Adopting JSON:API for the envelope alone Adopt it for the normalised cache, or use a plain envelope
Emitting _links and calling it HAL Conform fully or use your own links member
Using a generic generator with JSON:API Use a format-aware client library
Format-specific error bodies One application/problem+json contract estate-wide
Mixing formats across services Pick one per estate; consumers pay for the inconsistency
Choosing on aesthetics Generate a client from each and read the call site

FAQ

Is JSON:API worth adopting for an internal API?

Usually not. Its real payoff is a client-side store keyed by type and id — deduplicating shared records, updating every view when one changes, and fetching related resources in one compound document. That matters for a large front end consuming many interrelated resource types. For a service with two known consumers and a generated SDK, you pay the attributes indirection, the larger payload and the weaker generated types on every call, and receive none of the caching benefit. Adopt it when a client architecture will actually use it, not because it is specified.

Do generated SDKs handle JSON:API or HAL well?

Not out of the box. A generic OpenAPI generator emits exactly the document shape, so callers reach through attributes maps or _embedded collections and resolve relationships by hand — code that a format-aware library would provide for free. If you adopt one of these media types, plan on a format-specific client (or a hand-written façade over the generated one) as part of the decision rather than a surprise afterwards. The plain envelope’s main practical advantage is that generic tooling produces good types with no extra layer.

Can I use HAL links without adopting the whole format?

Yes, and it is often the sensible compromise. The valuable part of HAL is the idea of named link relations, and those relations — self, next, prev, first, last — are registered in RFC 8288, not owned by HAL. Put a links member in your own envelope, use the registered relation names, and clients that want to follow links can do so while everyone else ignores them. What to avoid is advertising application/hal+json for a document that does not follow HAL’s _links and _embedded rules, because a real HAL client will fail on it while you gain nothing from the claim.