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" }
}
}
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:
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.
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.
Related
- Response Envelopes & Metadata — up-link: the section defining envelope contents and metadata placement
- Query Patterns & Data Shaping Strategies — up-link: the parent reference for pagination, filtering and projection
- Envelope vs Top-Level Arrays in REST Responses — the prior decision this one builds on
- Adding Sparse Fieldset Support to OpenAPI Specs — the projection convention JSON:API specifies and plain envelopes must define
- REST vs GraphQL Pagination Contract Tradeoffs — a neighbouring comparison of response contracts