Implementing the JSON:API Specification: Server, Client, Tooling
Last updated:
This guide is the implementation companion to our JSON:API specification overview — where that page explains what the JSON:API v1.1 envelope looks like, this one shows how to actually ship a compliant server and client, with runnable code in Node, Ruby, Python, and Java. The spec is short (about 30 printed pages) but the distance between reading it and shipping a conformant endpoint is paved with content negotiation rules, query parameter parsing, compound-document serialization, and error-object shape — all of which existing libraries solve so you do not have to. The sections below survey the production-ready server and client libraries by language, then walk through the implementation details (content negotiation, top-level members, sparse fieldsets, includes, pagination, error format) with copy-pasteable examples. By the end you will know which library to reach for, what wire format to expect, and how to validate that your server is actually returning JSON:API and not just JSON.
Building a JSON:API server and unsure your response shape is conformant? Drop the payload into Jsonic's JSON Validator for syntax checks, then pair with the official JSON:API JSON Schema for envelope validation — both flag the most common implementation mistakes (numeric IDs, attributes outside the wrapper, missing type field) in seconds.
Validate a JSON:API responseJSON:API v1.1 (Dec 2022): what changed from 1.0
JSON:API v1.1 is a strictly additive update — every 1.0 document is also a valid 1.1 document, and every 1.0 client interoperates with a 1.1 server without changes. The additions land in five places.
- Extensions and profiles got a formal mechanism. The media type now accepts
ext(changes semantics) andprofile(adds semantics without changing existing ones). A response withContent-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"advertises the atomic-operations extension; clients sendAcceptwith the same parameters to negotiate. - The lid (local ID) member. A resource object may now carry a
lidalongside (or instead of)id, letting a client create several related resources in a single request before the server has assigned real IDs. The lid is scoped to the request and replaced with the real id in the response. - Link objects gained describedby, type, and hreflang. Links can point to documentation (
describedby), declare the media type of the target, and specify language. - Error object aligned with RFC 9457. Fields (
id,status,code,title,detail,source,meta) map cleanly to Problem Details, so a JSON:API error object also satisfies clients expecting RFC 9457 with light wrapper logic. See our error handling guide. - Clarifications. 1.0 ambiguities were resolved — member name characters, the relationship between
dataandincluded, and the meaning of an emptydata: nullon to-one relationships are spelled out unambiguously.
Server implementation libraries: Node, Rails, Python, Java
Building JSON:API from scratch is a multi-week project; using a library is a one-afternoon task. The mature options by language:
| Language | Library | Best for |
|---|---|---|
| Node.js | jsonapi-server | Standalone Express-style server with built-in resource definitions, includes, sparse fields, pagination |
| Node.js | kitsu-core + custom handler | Lightweight serializer when you already have your own routing |
| Ruby on Rails | jsonapi-resources | Full-featured Rails engine — controllers, serializers, filtering, includes, sparse fields all declarative |
| Ruby on Rails | jsonapi-serializer (formerly fast_jsonapi) | Serialization-only when you want to keep your own controllers |
| Python | jsonapi-pyramid / pyramid_jsonapi | Auto-generates JSON:API endpoints from SQLAlchemy models |
| Python | marshmallow-jsonapi | Schema-driven serialization on Flask, FastAPI, or any framework |
| Java / Kotlin | Crnk | Declarative resource framework with Spring Boot integration, full v1.1 support |
| Java | Elide | JSON:API + GraphQL on top of JPA entities with built-in security model |
| PHP | laravel-json-api | Laravel-native, see our Laravel JSON:API guide |
An Express + jsonapi-server resource definition is enough to expose a fully compliant collection:
// Node.js — jsonapi-server resource definition
const jsonApi = require('jsonapi-server')
jsonApi.setConfig({ port: 8080, hostname: 'localhost', protocol: 'http' })
jsonApi.define({
resource: 'articles',
handlers: require('./handlers/articles'),
attributes: {
title: jsonApi.Joi.string().required(),
body: jsonApi.Joi.string(),
createdAt: jsonApi.Joi.date(),
author: jsonApi.Joi.one('people'),
comments: jsonApi.Joi.many('comments'),
},
})
jsonApi.start()
// GET /articles?include=author,comments.user → spec-compliant compound documentThe library handles content negotiation, the include parameter parsing, sparse fieldsets, pagination links, and error format — your handler just returns the raw model.
Content negotiation: application/vnd.api+json and the ext parameter
Content negotiation is the part of JSON:API most often skipped, and the part most tested by conformance suites. The spec requires three behaviors.
(1) Every successful response sets Content-Type: application/vnd.api+json. No charset parameter, no other suffix — the media type itself implies UTF-8.
(2) If the client sends a request with Content-Type: application/vnd.api+json that includes any media type parameters the server does not understand (e.g. an unknown ext value), respond with HTTP 415 Unsupported Media Type.
(3) If the client's Accept header lists application/vnd.api+json only with parameters the server cannot satisfy, respond with HTTP 406 Not Acceptable. If the client lists it without parameters, the server may respond with any extensions it chooses.
// Express middleware enforcing JSON:API content negotiation
import { Request, Response, NextFunction } from 'express'
const MEDIA = 'application/vnd.api+json'
const SUPPORTED_EXT = 'ext="https://jsonapi.org/ext/atomic"'
const okParams = (p: string) => p === '' || p === SUPPORTED_EXT
export function jsonApiNegotiate(req: Request, res: Response, next: NextFunction) {
const ct = req.headers['content-type']
if (ct?.startsWith(MEDIA) && !okParams(ct.slice(MEDIA.length).trim())) {
return res.status(415).end() // Content-Type params unsupported
}
const accept = (req.headers.accept || '').split(',').map(v => v.trim())
const variants = accept.filter(v => v.startsWith(MEDIA))
if (variants.length && !variants.some(v => okParams(v.slice(MEDIA.length).trim()))) {
return res.status(406).end() // Accept params unsupported
}
res.setHeader('Content-Type', MEDIA)
next()
}The ext parameter is how extensions opt in. Clients that need atomic operations send Accept: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"; servers that implement it echo the parameter in their response Content-Type. Servers that do not implement an extension simply ignore the request parameter and respond with bare application/vnd.api+json.
Top-level: data, errors, meta, links, included, jsonapi
Every JSON:API document is a JSON object with a fixed vocabulary of top-level members. Exactly one of data, errors, or meta must be present. The optional members are jsonapi, links, and included. No other top-level keys are allowed.
// Complete JSON:API response with all common top-level members
{
"jsonapi": { "version": "1.1" },
"links": {
"self": "https://api.example.com/articles?page[number]=1",
"first": "https://api.example.com/articles?page[number]=1",
"last": "https://api.example.com/articles?page[number]=8",
"next": "https://api.example.com/articles?page[number]=2",
"prev": null
},
"data": [{
"type": "articles",
"id": "1",
"attributes": {
"title": "JSON:API in practice",
"body": "Implementation notes...",
"createdAt": "2026-05-23T10:00:00Z"
},
"relationships": {
"author": { "data": { "type": "people", "id": "42" } },
"comments": { "data": [{ "type": "comments", "id": "100" }] }
},
"links": { "self": "https://api.example.com/articles/1" }
}],
"included": [
{ "type": "people", "id": "42", "attributes": { "name": "Ada Lovelace" } },
{ "type": "comments", "id": "100", "attributes": { "body": "Great post" },
"relationships": { "user": { "data": { "type": "people", "id": "42" } } } }
],
"meta": { "totalRecords": 156 }
}Notes on the shape worth memorizing: type and id are required strings on every resource object — even when the database column is an integer, the wire value is a string. The attributes object may not contain a type or id key. Relationships are linkage objects, not embedded resources — the actual related data lives in included at the top level. metais the spec's escape hatch for non-standard data; put pagination counts, rate-limit info, or anything else the spec does not define there.
Sparse fieldsets and the fields[type]= query parameter
Sparse fieldsets let a client request only the attributes it actually needs, cutting response size and processing cost. The syntax is fields[type]=key1,key2 repeated per type. A request for articles with their authors but only specific fields on each:
GET /articles?include=author&fields[articles]=title,body&fields[people]=name
Accept: application/vnd.api+json
// Response — every article in data has only title and body in attributes,
// every person in included has only name. relationships still appear.
{
"data": [{
"type": "articles", "id": "1",
"attributes": { "title": "...", "body": "..." },
"relationships": { "author": { "data": { "type": "people", "id": "42" } } }
}],
"included": [{
"type": "people", "id": "42",
"attributes": { "name": "Ada Lovelace" }
}]
}Server-side, the implementation is a serialization-time filter. Parse each fields[type]value into a Set of allowed attribute keys, then during serialization, look up the Set for each resource's type and drop any attribute key not in the Set. Leave type, id, and relationships alone unless the request explicitly filtered relationships too.
// Python serializer applying sparse fieldsets
def serialize_resource(model, type_name, fields_map):
attrs = model.to_dict()
allowed = fields_map.get(type_name)
if allowed is not None:
attrs = {k: v for k, v in attrs.items() if k in allowed}
return {
"type": type_name,
"id": str(model.id),
"attributes": attrs,
}
# Parse ?fields[articles]=title,body&fields[people]=name
def parse_fields(query):
out = {}
for key, value in query.items():
if key.startswith("fields[") and key.endswith("]"):
type_name = key[7:-1]
out[type_name] = set(value.split(","))
return outMost server libraries (jsonapi-resources, jsonapi-server, Crnk, marshmallow-jsonapi) do this automatically — you declare attributes on a resource definition and the library handles the filter. Only roll your own when writing a custom serializer.
Filtering, sorting, pagination — the spec's loose conventions
JSON:API reserves the parameter names (filter, sort, page) but does not prescribe their syntax — the spec calls these "strong recommendations rather than requirements." Pick an internal convention, document it, stay consistent.
- Sorting — comma-separated fields with optional
-prefix for descending:sort=-createdAt,title. - Filtering — simple shops use
filter[field]=valuefor equality; richer setups use operator syntax likefilter[createdAt][gte]=2026-01-01.jsonapi-resourcesin Rails uses method-style operators; Crnk uses a query DSL. - Pagination — three idiomatic strategies, spec endorses none. Page-based (
page[number]=2&page[size]=20) for stable lists, offset-based (page[offset]=40&page[limit]=20) for random-access aggregates, cursor-based (page[cursor]=eyJpZCI6MTIzfQ) for high-volume or append-heavy data. The response must include alinksobject withfirst,last,prev,next— set unavailable links tonull, do not omit. See our JSON API pagination guide.
// Rails — cursor pagination links in jsonapi-resources style
{
"data": [/* 20 articles */],
"meta": { "totalRecords": 4827 },
"links": {
"self": "/articles?page[size]=20&page[cursor]=eyJpZCI6MTIwfQ",
"first": "/articles?page[size]=20",
"next": "/articles?page[size]=20&page[cursor]=eyJpZCI6MTQwfQ",
"prev": "/articles?page[size]=20&page[cursor]=eyJpZCI6MTAwfQ",
"last": null
}
}For REST API design philosophy more broadly, the same conventions extend to non-JSON:API endpoints — consistency across services often matters more than spec conformance.
Client libraries: ember-data, jsonapi-fractal, axios-jsonapi
On the client side the raw envelope is denser than what UI code wants — most apps deserialize JSON:API into flat resource objects with relationships resolved into real references, then re-serialize on write. Libraries handle both directions.
| Library | Stack | Strength |
|---|---|---|
ember-data | Ember.js | JSON:API is the default adapter — zero config for Ember apps |
jsonapi-fractal | Framework-agnostic | Bidirectional serializer with relationship resolution |
axios-jsonapi / axios-jsonapi-deserializer | Any axios app | Interceptors that auto-deserialize responses and serialize requests |
devour-client | Node / Browser | Resource-style client with includes and middleware |
kitsu | Browser / Node | Lightweight fetch wrapper with sparse fieldsets and includes |
// axios + axios-jsonapi-deserializer
import axios from 'axios'
import deserialize from 'axios-jsonapi-deserializer'
const api = axios.create({
baseURL: 'https://api.example.com',
headers: {
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
},
})
api.interceptors.response.use((res) => { res.data = deserialize(res.data); return res })
// Fetch articles with their author and comments, sparse fields applied
const { data } = await api.get('/articles', {
params: {
'include': 'author,comments.user',
'fields[articles]': 'title,body',
'fields[people]': 'name',
'sort': '-createdAt',
'page[size]': 20,
},
})
// data is a flat array — relationships materialized as real objects, not identifiers
console.log(data[0].title, data[0].author.name, data[0].comments[0].user.name)For Ember apps the story is simpler — ember-data reads JSON:API natively, materializes everything into the store, and resolves relationships automatically. No deserializer setup, no envelope handling — just this.store.findAll('article') and the records appear.
When NOT to use JSON:API (vs OpenAPI/REST/GraphQL)
JSON:API earns its envelope cost in specific situations and pays for it poorly in others. The honest decision tree:
- Use plain REST + OpenAPI when you control the response shape per endpoint and your clients are happy with arbitrary JSON bodies. OpenAPI documents whatever shape you choose; you skip the envelope tax and per-screen optimization becomes trivial. This is the right default for most internal APIs.
- Use GraphQL when clients vary widely in what fields they need, when nested fetching is the common case, and when you control both ends. GraphQL lets clients ask for exactly the fields they want at any depth in one request — sparse fieldsets and includes are baked in, not bolted on. See our JSON vs GraphQL vs gRPC comparison.
- Use gRPC when latency and throughput matter more than browser accessibility, when the wire format itself can be binary (Protobuf), and when clients are services rather than humans. JSON:API is an HTTP/JSON spec and does not compete here.
- Use JSON:API when you want a uniform envelope across many endpoints, when clients are typed and would benefit from consistent shape across services (Ember, React Native with a shared client library), when sideloading via includes is the natural fetch pattern, and when you do not want to invent your own conventions per endpoint.
- Use HATEOAS-flavored REST (HAL, Siren) when you want hypermedia discoverability without the JSON:API resource-relationship model. See our HATEOAS guide.
The trap to avoid: adopting JSON:API mid-project because it looks tidy on the spec page, then fighting its sideloading model when a single screen needs a cross-resource aggregation that does not fit. The envelope is fixed; if your read patterns do not map cleanly to resources and relationships, GraphQL or a custom shape will pay off faster.
Error response in JSON:API format
When something goes wrong, the response replaces data with errors — an array of error objects, even for a single error. The fields align with RFC 9457 Problem Details so a downstream client can treat the same payload as either format.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/vnd.api+json
{
"errors": [
{
"id": "err-7e3a1c",
"status": "422",
"code": "validation.required",
"title": "Missing required attribute",
"detail": "The attribute 'title' is required for resource type 'articles'",
"source": { "pointer": "/data/attributes/title" }
},
{
"id": "err-7e3a1d",
"status": "422",
"code": "validation.format",
"title": "Invalid relationship type",
"detail": "Expected 'people' for relationship 'author', got 'users'",
"source": { "pointer": "/data/relationships/author/data/type" }
}
],
"jsonapi": { "version": "1.1" }
}The source.pointer is a JSON Pointer (RFC 6901) into the request body — clients use it to highlight the exact field that failed. For validation errors on query parameters, use source.parameter instead. The top-level HTTP status code should be the most general status that applies; individual error objects can carry their own status for batch responses.
JSON Schema validation for response shape
The official JSON:API repository publishes a JSON Schema for the v1.1 envelope. Run outgoing responses through it in development and CI to catch envelope mistakes — wrong key names, missing required fields, attributes outside the wrapper — before a client sees them.
// Node — validate a JSON:API response with Ajv
import Ajv from 'ajv'
import jsonApiSchema from 'jsonapi-v1.1-schema.json' // from jsonapi.org
const ajv = new Ajv({ allErrors: true, strict: false })
const validate = ajv.compile(jsonApiSchema)
export function assertJsonApi(body: unknown) {
if (!validate(body)) {
const errors = (validate.errors ?? []).map(e => `${e.instancePath} ${e.message}`)
throw new Error('JSON:API envelope violation:\n' + errors.join('\n'))
}
}
// Use in CI tests against every endpoint
const body = await (await fetch('http://localhost:8080/articles')).json()
assertJsonApi(body)Same pattern works in Python with jsonschema, Ruby with json-schema, and Java with networknt/json-schema-validator. Wire it into your test harness so every endpoint round-trips through the validator on every CI run.
Key terms
- resource object
- The basic unit of a JSON:API document — a JSON object with a required
type(string) andid(string), plus optionalattributes,relationships,links, andmetamembers. - compound document
- A response that includes related resources in the top-level
includedarray, requested via theincludequery parameter. Eliminates N+1 client fetches. - sparse fieldset
- A request for only a subset of attributes on a resource type, expressed as
fields[type]=key1,key2. The server filters response attributes to match. - relationship object
- A pointer from one resource to another, expressed as a
datamember with the related resource'stypeandid— not the embedded resource itself. The actual resource appears inincluded. - lid (local ID)
- A client-assigned identifier (added in v1.1) used during creation requests to link related resources before the server assigns real IDs. Scoped to the request, replaced in the response.
- extension
- A negotiated capability that changes JSON:API semantics, advertised via the
extmedia type parameter. The atomic-operations extension is the canonical example. - profile
- A negotiated capability that adds semantics without changing existing ones, advertised via the
profilemedia type parameter.
Frequently asked questions
Who uses JSON:API in production?
JSON:API has the strongest adoption inside the Ember ecosystem — Ember Data ships JSON:API as the default adapter, so any Ember app talking to a typed backend is likely on JSON:API. Beyond Ember, GitHub uses JSON:API conventions in parts of its v3 API, Heroku exposes JSON:API endpoints for its dashboard, and the Drupal CMS ships a built-in JSON:API server that powers headless Drupal sites. Rails shops adopt it through the jsonapi-resources or jsonapi-serializer gems, often pairing with React Native or Vue front-ends that want a typed envelope. In the Java world, the Crnk framework exposes JSON:API endpoints with declarative resource mapping. The spec is most popular for internal APIs at companies that own both server and client and want a single, documented response shape across many endpoints rather than reinventing one per service.
What's new in JSON:API v1.1 vs 1.0?
JSON:API v1.1 was published in December 2022 and is a backward-compatible refinement of 1.0. The biggest additions: extensions and profiles got a formal mechanism (the ext and profile media type parameters), allowing a server to advertise non-standard behavior without breaking conformance — for example application/vnd.api+json; ext="https://jsonapi.org/ext/atomic" enables atomic operations. The lid (local ID) member was added to support creating multiple related resources in one request before the server assigns IDs. Link objects gained a describedby member to point to documentation, plus type and hreflang. The error object aligned with RFC 9457 (problem details). And the spec clarified ambiguous parts of 1.0 around relationships, included data, and member name characters. No fields were removed — a 1.0 client talks to a 1.1 server fine.
Is JSON:API the same as REST or different?
JSON:API is a specific flavor of REST — it follows REST principles (resources identified by URLs, HTTP verbs for operations, stateless interactions) but adds a strict response envelope and a defined query parameter syntax that plain REST does not require. Plain REST gives you a method (GET /users/123) and asks you to invent the response shape; JSON:API gives you the method plus a binding contract for the response (top-level data, attributes, relationships, links, included, meta). It also standardizes things REST leaves vague: filtering syntax (filter[status]=active), sorting (sort=-createdAt), pagination links, sparse fieldsets, and error format. So a JSON:API endpoint is REST, but a REST endpoint is rarely JSON:API. The trade-off is uniformity (clients work across services) versus flexibility (you cannot shape the response freely for a single screen the way GraphQL would).
How does JSON:API handle pagination?
JSON:API defines the query parameter family (page[...]) and the link member names (first, last, prev, next) but intentionally leaves the strategy open — the server picks the model that fits its data store. The three common strategies: page-based (page[number]=2&page[size]=20), offset-based (page[offset]=40&page[limit]=20), and cursor-based (page[cursor]=eyJpZCI6MTIzfQ). Cursor pagination is the right choice for high-volume or append-only data because it survives inserts; page numbers are fine for small admin lists; offset works for cached aggregates. Whichever you pick, the response must include a links object with first, last, prev, and next URLs that point to the same endpoint with the appropriate page[] parameters filled in. Clients then follow links rather than computing offsets themselves — a hypermedia property the spec inherits from REST. See our JSON API pagination guide for full implementations.
What is the included array used for?
The top-level included array carries related resources that the client requested via the include query parameter — it is JSON:API's mechanism for compound documents (sideloading). Instead of embedding a full author object inside every post, the response sends each post once in data with a relationships pointer (the author's type and id), and the actual author resource appears once in included. Two benefits: the client gets all the data in one round trip (no N+1 fetches), and a related resource referenced by ten different posts is sent once instead of ten times. The client requests includes with a comma-separated list and dot notation for depth: GET /posts?include=author,comments.user fetches posts, their authors, their comments, and the user of each comment. The server must include those resources in the included array; resources not requested must not appear there.
Can I use OpenAPI to document a JSON:API endpoint?
Yes — OpenAPI (the schema language) and JSON:API (the response shape) operate at different layers and combine well. OpenAPI describes the surface of your API (paths, parameters, response schemas, security); JSON:API describes the structure of every response body. You write an OpenAPI document that says "GET /articles returns 200 with body matching the JSON:API article schema" — the body schema references reusable components for the JSON:API envelope (top-level data/included/meta/links shapes). The jsonapi.org site links to community-maintained OpenAPI schemas for the v1.1 envelope; tools like jsonapi-openapi-rb and the @jsonapi-ts/openapi generator produce OpenAPI specs directly from JSON:API resource definitions. Stoplight and Redocly render JSON:API endpoints documented this way exactly like any other OpenAPI endpoint — the integration is transparent to the rendering tools.
How do I implement sparse fieldsets server-side?
Parse the fields[type] query parameters before serialization, then filter the attributes (and optionally relationships) on each resource of that type to only the requested keys. A request like GET /articles?fields[articles]=title,body&fields[people]=name expects articles in data to include only title and body, and any person resource in included to include only name. Implementation steps: (1) read the query, splitting each fields[type] value on commas to get a Set of allowed keys per type; (2) during serialization of each resource, check its type against the fields map and, if a filter exists for that type, drop attributes whose keys are not in the Set; (3) leave type, id, and (per spec) relationships intact unless the request explicitly filtered relationships too. Most server libraries do this for you — jsonapi-serializer in Ruby, jsonapi-server in Node, Crnk in Java all parse fields[] automatically. Roll your own only when you need a custom serialization layer.
JSON:API vs JSON Schema — are they competing standards?
They are not competing — they solve different problems and frequently live together. JSON:API is a specification for the shape of REST API responses (top-level keys, resource objects, links, includes); JSON Schema is a specification for validating that any JSON document matches a defined structure. You use JSON Schema to validate that an incoming request body or outgoing response body conforms to the JSON:API envelope — the official jsonapi.org publishes a JSON Schema for the v1.1 spec that you can plug into Ajv (Node), python-jsonschema, or any JSON Schema validator. Many JSON:API servers run incoming requests through JSON Schema validation in middleware to catch malformed payloads before they hit business logic. So the typical stack is: JSON:API defines what the body looks like, JSON Schema enforces that it actually looks that way. Different layers, same payload.
Further reading and primary sources
- JSON:API Specification v1.1 — The canonical spec — short, normative, December 2022 revision
- JSON:API Implementations — Official directory of server, client, and helper libraries by language
- JSON:API JSON Schema — Official JSON Schema for validating v1.1 response envelopes
- RFC 9457 — Problem Details for HTTP APIs — The error-format RFC that JSON:API v1.1 error objects align with
- jsonapi-server (Node) — Production-ready Node.js JSON:API server with declarative resources
- jsonapi-resources (Rails) — Rails engine for JSON:API — controllers, serializers, filters declarative
- Crnk (Java) — Java JSON:API framework with Spring Boot integration and full v1.1 support