The API, by example
JSON:API is a standard for how a REST API shapes its requests and responses — so clients, docs, caches and codegen can be shared instead of rebuilt per endpoint. This is the guided tour: the moves every API makes — read one, read many, filter, shape, write, and batch — each shown as the raw HTTP and the typed TypeScript client, side by side. No prior JSON:API knowledge assumed.
The words you'll see
JSON:API gives a few plain ideas precise names. Here they are up front — everything below is just these, in motion.
One thing your API exposes — an album, an artist. Every resource has a type and an id, and carries its data as attributes.
Typed links between resources — an album belongs to an artist and has many tracks. Each is addressable, and can be fetched, counted or paginated on its own.
Ask for related resources with ?include= and they ride along in the same response under included — one round trip, no N+1.
?fields[albums]=title,artist trims each resource to just the members you name — smaller payloads, on demand.
Every request and response is application/vnd.api+json. That one header is how server and client agree they're speaking JSON:API.
Failures come back as a structured errors array — each with a status, a human detail and a source pointer at the exact field. A 422 maps straight onto a form.
One class, the whole surface
Everything on the rest of this page — every request, every response — is served by this one resource class. You declare a type's fields, which are sortable, and its relationships; the library parses each request, validates it, loads the data and shapes the spec-compliant response. No controllers, no per-endpoint serializers.
required, maxLength, …) baked inBelongsTo, HasMany, countable and paginablefinal class AlbumResource extends AbstractResource
{
public static string $type = 'albums';
public function fields(): array
{
return [
Id::make(),
Str::make('title')->required()->sortable(),
Str::make('status')->sortable(),
DateTime::make('releasedAt')->sortable(),
BelongsTo::make('artist', 'artists'),
HasMany::make('tracks', 'tracks')->countable(),
];
}
}
// the same class runs standalone — add #[AsJsonApiResource]
// to auto-wire it in Symfony or Laravel. No controller either way.
Fetch one
GET /albums/1 returns one resource: its attributes, and its relationships as bare identifiers (a { type, id }, or a link to fetch them). In the client that becomes one flat, typed object — attributes are plain properties, the envelope rides on the side.
Request
GET /albums/1
Accept: application/vnd.api+json
Response · 200 OK
{
"data": {
"type": "albums",
"id": "1",
"attributes": {
"title": "OK Computer",
"status": "released",
"releasedAt": "1997-05-21"
},
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } },
"tracks": { "links": { "related": "/albums/1/tracks" }, "meta": { "count": 12 } }
},
"links": { "self": "/albums/1" }
}
}
const album = await client.albums.get('1')
// the wire envelope is materialised into one flat object.
// with Identifier<T> = { type: T; id: string }, `album` is typed:
type Album = {
type: 'albums'
id: string
title: string
status: AlbumStatus // 'draft' | 'released'
releasedAt: string
artist: Identifier<'artists'> | null // not included → an identifier
tracks: Collection<Identifier<'tracks'>> // not included → identifiers
}
album.title // string, flat — no `.attributes`
album.artist?.id // '9' — read the id with no extra fetch
album.$self // '/albums/1' — envelope on a non-enumerable accessor
Fetch a collection
A list read returns an array of resources plus links to page through them. The client hands you a genuine array — a Collection<Album> — with the JSON:API envelope and page navigation riding on the side.
Request
GET /albums
Accept: application/vnd.api+json
Response · 200 OK
{
"data": [
{
"type": "albums",
"id": "1",
"attributes": {
"title": "OK Computer",
"status": "released",
"releasedAt": "1997-05-21"
},
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } },
"tracks": { "links": { "related": "/albums/1/tracks" }, "meta": { "count": 12 } }
}
},
{
"type": "albums",
"id": "7",
"attributes": {
"title": "Kid A",
"status": "released",
"releasedAt": "2000-10-02"
},
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } },
"tracks": { "links": { "related": "/albums/7/tracks" }, "meta": { "count": 10 } }
}
}
],
"links": { "self": "/albums", "next": "/albums?page[number]=2" },
"meta": { "page": { "total": 42 } }
}
const albums = await client.albums.list()
// a real, read-only array — the relationship-level envelope
// (pagination, links, navigation) rides on $-accessors:
type Collection<T> = readonly T[] & {
readonly $page: PageInfo // { kind: 'page' | 'cursor' | … }
$next(): Promise<Collection<T> | undefined>
$prev(): Promise<Collection<T> | undefined>
// …$links, $meta
}
const page: Collection<Album> = albums
albums[0].title // typed — map / filter / find all work
albums.$page.kind // 'page'
await albums.$next?.() // → the next Collection<Album>, if the server linked one
Filter & sort
Filter and sort compose on a list read. In the client, every key is narrowed to what the server actually advertises for that type — a filter key or sort field the API doesn't support is a compile error, the static mirror of the server's 400.
Request
GET /albums?filter[title]=OK&sort=-releasedAt
Accept: application/vnd.api+json
Response · 200 OK
{
"data": [
{
"type": "albums",
"id": "1",
"attributes": {
"title": "OK Computer",
"status": "released",
"releasedAt": "1997-05-21"
},
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } }
}
}
],
"links": { "self": "/albums?filter[title]=OK&sort=-releasedAt" }
}
const albums = await client.albums.list({
filter: { title: 'OK' },
sort: '-releasedAt',
}) // → Collection<Album>
// filter keys and sort tokens are the exact vocabulary the
// server advertises for `albums` — nothing more:
type AlbumSort =
'title' | '-title' | 'releasedAt' | '-releasedAt' | 'status' | '-status'
type AlbumFilterKey =
'title' | 'q' | 'rating' | 'releasedAt' | 'artist.name' | 'tracks'
// sort: 'plays' → compile error (not an AlbumSort)
// filter: { colour: '…' } → compile error (not an AlbumFilterKey)
Compound documents
A relationship is an identifier until you ask for it. Add ?include=artist,tracks and those related resources ride along in the same response under included — one round trip, no N+1. The wire ships each related resource once and references it by type/id; the client stitches those references back into the parent as real nested objects. And because include is captured as a literal tuple, the result type widens with it — the relations you named become hydrated resources, the rest stay identifiers.
Request
GET /albums/1?include=artist,tracks
Accept: application/vnd.api+json
Response · 200 OK
{
"data": {
"type": "albums",
"id": "1",
"attributes": { "title": "OK Computer" },
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } },
"tracks": { "data": [{ "type": "tracks", "id": "31" }, { "type": "tracks", "id": "32" }] }
}
},
"included": [
{ "type": "artists", "id": "9", "attributes": { "name": "Radiohead" } },
{ "type": "tracks", "id": "31", "attributes": { "title": "Airbag" } },
{ "type": "tracks", "id": "32", "attributes": { "title": "Paranoid Android" } }
]
}
const album = await client.albums.get('1', {
include: ['artist', 'tracks'],
})
// the named relations widen from identifiers to hydrated resources
// (Artist = { type:'artists'; id; name }, Track = { …; title }):
type Result = Album & {
artist: Artist | null // to-one → the full resource, or null
tracks: Collection<Track> // to-many → augmented array of resources
}
album.artist?.name // 'Radiohead' — hydrated, no cast
album.tracks[0].title // 'Airbag'
album.tracks.$page // the tracks relation is itself paginated
// hydration is one hop deep: album.tracks[0].album stays an
// identifier — include the dotted path 'tracks.album' to widen it too.
Sparse fieldsets
fields[albums]=title,status tells the server to emit only those members of each album — every other attribute and relationship is dropped from the payload. In the client the effect is the same on the type: an unrequested member is statically absent, so reading it is a compile error, not a runtime undefined. Smaller payloads and a tighter type, from one query parameter.
Request
GET /albums?fields[albums]=title,status
Accept: application/vnd.api+json
Response · 200 OK
{
"data": [
{
"type": "albums",
"id": "1",
"attributes": { "title": "OK Computer", "status": "released" }
// releasedAt, and the artist / tracks relationships — all omitted
},
{
"type": "albums",
"id": "7",
"attributes": { "title": "Kid A", "status": "released" }
}
]
}
const albums = await client.albums.list({
fields: { albums: ['title', 'status'] },
})
// the result type is narrowed to exactly the named members —
// everything else is ABSENT from the type, matching the wire:
type NarrowedAlbum = {
type: 'albums'
id: string
title: string
status: AlbumStatus
// releasedAt — absent (not requested)
// artist, tracks — absent (relations are fieldset members too)
}
albums[0].title // ok
albums[0].releasedAt // ✗ compile error — not in fields[albums]
albums[0].artist // ✗ compile error — the relation was trimmed off
Create, update & delete
Writes are the same envelope in reverse. The client takes a flat attributes object — no data, no attributes wrapper, no repeated type — and hands back the materialised resource. A rejected write throws a typed error mapped back to the field you sent.
Request · create
POST /albums
Content-Type: application/vnd.api+json
{ "data": { "type": "albums", "attributes": { "title": "Kid A", "status": "released" } } }
Response · 201 Created
Location: /albums/10
{ "data": { "type": "albums", "id": "10", "attributes": { "title": "Kid A", "status": "released" } } }
Update & delete
PATCH /albums/10 # partial — send only what changed → 200
{ "data": { "type": "albums", "id": "10", "attributes": { "title": "OK Computer (Remaster)" } } }
DELETE /albums/10 # → 204 No Content
// create input is generated from the write schema — flat, no envelope,
// required fields required, enums narrowed, relations routed for you:
type AlbumsCreateAttributes = {
title: string // required
status?: AlbumStatus
releasedAt?: string
artist?: Identifier<'artists'> // relations → data.relationships
}
const created = await client.albums.create({ title: 'Kid A', status: 'released' })
// created: Album — materialised like a read, id now server-assigned
created.id // '10'
await client.albums.id('10').update({ title: 'OK Computer (Remaster)' }) // Partial<…>
await client.albums.id('10').delete() // Promise<void>
// a rejected write throws a typed JsonApiError, remapped to your input keys:
// error.byPath()['title']?.[0]?.detail // 'must not be blank'
Atomic operations
The JSON:API atomic extension batches several writes into one all-or-nothing request. A later operation can reference a resource an earlier one just created — by a local id (lid) on the wire, or just the returned handle in the client. It all commits together, or none of it does.
Request
POST /operations
Content-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"
{
"atomic:operations": [
{
"op": "add",
"data": {
"type": "artists",
"lid": "atomic-0",
"attributes": { "name": "Boards of Canada" }
}
},
{
"op": "add",
"data": {
"type": "albums",
"attributes": { "title": "Geogaddi" },
"relationships": {
"artist": { "data": { "type": "artists", "lid": "atomic-0" } }
}
}
}
]
}
Response · 200 OK
{
"atomic:results": [
{
"data": {
"type": "artists",
"id": "99",
"attributes": { "name": "Boards of Canada" }
}
},
{
"data": {
"type": "albums",
"id": "42",
"attributes": { "title": "Geogaddi" }
}
}
]
}
const [artist, album] = await client.atomic((tx) => {
const a = tx.create({ type: 'artists', name: 'Boards of Canada' })
// `a` doubles as a { type, lid } ref — drop it into a later op
const b = tx.create({ type: 'albums', title: 'Geogaddi', artist: a })
return [a, b] as const
})
// each returned handle resolves to its own materialised result,
// typed positionally by the tuple you returned:
type AtomicResult<T> = { data: T; meta?: Meta }
// artist: AtomicResult<Artist>
// album: AtomicResult<Album>
artist.data.id // '99' — the real, server-assigned id
album.data.title // 'Geogaddi' — one round trip, one transaction
That's the tour
Same shapes, whichever way you build — the Symfony bundle, the Laravel package, or the framework-agnostic core — with a typed TypeScript client generated from the very same contract. Pick a stack and go.