Skip to content

Getting started

From zero to a first typed read and write against your JSON:API. You generate a client from your API's OpenAPI document, create it once, then read and write with full end-to-end types — no hand-written models, no casts.

Prerequisites

Your API is built on haddowg/json-api — via the Symfony bundle, the Laravel package, or the core in your own stack — and exposes an OpenAPI 3.1 document (usually at /docs.json) and, optionally, a JSON Schema map (at /schemas.json). That document is the contract the codegen reads — see concepts for why. No API yet? Step 2 below can run against this repo's committed example spec.

1. Install

The codegen is a dev-only dependency: the client it generates has no runtime tie back to it. The runtime (and, if you want caching, the TanStack Query bindings) are ordinary runtime dependencies.

# dev-only — generates the client, then gets out of the way
npm install -D @haddowg/json-api-codegen

# runtime — the generic client the generated module binds to
npm install @haddowg/json-api-client

# optional — TanStack Query bindings (add when you want caching + normalisation)
npm install @haddowg/json-api-query

2. Generate the client

Point the codegen at your API's OpenAPI document and choose where the client lands. Add --schemas to also emit the per-type JSON Schema map that powers the optional validation seam.

npx json-api-codegen \
  --input https://music.example/docs.json \
  --output src/api/music.gen.ts \
  --schemas https://music.example/schemas.json

No running API yet?

Generate from the committed music-catalog spec this repo's own clients are generated from and conformance-tested against — immediately runnable as-is:

npx json-api-codegen \
  --input https://raw.githubusercontent.com/haddowg/json-api-ts/main/packages/codegen/test/fixtures/music-catalog.openapi.json \
  --output src/api/music.gen.ts

--input and --schemas each accept an http(s) URL or a local file (JSON or YAML) — so you can generate straight from a running server or from a spec fixture committed to your repo. This writes two files:

  • src/api/music.gen.ts — the typed client (runtime descriptor + types + a bound createClient);
  • src/api/music.schemas.gen.ts — the per-type schema map (only with --schemas).

Commit the generated files

The output is one small, reviewable, diffable module — commit it into your repo, à la openapi-typescript. It imports @haddowg/json-api-client at runtime; regenerate it whenever the API changes. The header carries a deterministic provenance stamp (source spec + a content hash), and --check gives you a CI drift gate. See codegen for the full flag reference, the programmatic API, and multi-server output.

3. Create the client

The generated createClient bakes in the descriptor and the server's atomic capability, so you supply only options. transport defaults to the global fetch when omitted — the minimal client is just a baseUrl.

import { createClient } from './api/music.gen'

const client = createClient({
  baseUrl: 'https://music.example',
})

Add a headers provider for auth — it runs per request and may be async, so a fresh bearer token is fetched each time:

const client = createClient({
  baseUrl: 'https://music.example',
  headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
})
Going deeper: swapping the transport

The transport seam is a tiny fetch-shaped function, so undici, an axios adapter, or a test mock all drop in. The example.test.ts reference wires a mock transport that replays captured fixtures; in a real app you omit transport entirely and let the client use global fetch. See the @haddowg/json-api-client README for the transport contract and the full ClientOptions table.

4. First read

Read a collection with a typed filter and sort. include hydrates a related resource into the result type; fields narrows the result to exactly the selected attributes.

const albums = await client.albums.list({
  filter: { title: 'OK' },
  sort: '-releasedAt',
  include: ['artist'], // widens `artist` to a hydrated resource
  fields: { albums: ['title', 'status', 'artist'] },
  page: { number: 1 },
})

albums[0]!.title // typed string
albums[0]!.artist?.name // hydrated — `artist` is a full resource, not an identifier
albums.$page.kind // 'page' — pagination rides the array, count-free-safe

Fetching a single resource is just as flat:

const ok = await client.albums.get('1', { include: ['artist', 'tracks'] })
ok.title // string
ok.tracks // the to-many `tracks`, hydrated into an array of track resources

The wire envelope is materialised into flat resource objects: type/id and attributes are plain enumerable props, while the envelope (links, meta, pagination) hangs off non-enumerable $-accessors. See reading for the full read surface.

5. First write

Writes take flat input — the client builds the JSON:API envelope and materialises the response back into a flat resource.

// POST /albums — returns the materialised 201 body
const created = await client.albums.create({ title: 'Kid A', status: 'released' })
created.id // the server-assigned id

// PATCH /albums/1 — a partial patch
await client.albums.id('1').update({ title: 'OK Computer (Remaster)' })

// DELETE /albums/1 — resolves void (a 204)
await client.albums.id('1').delete()

A failed write throws a typed error you can key by your flat input path — ideal for form UX:

try {
  await client.albums.create({ title: '' })
} catch (e) {
  const error = e as import('@haddowg/json-api-client').JsonApiError
  error.isUnprocessable() // 422
  error.byPath()['title']?.[0]?.detail // 'must not be blank'
}

See writing for the full write surface (response shaping, relationship mutation from within a write) and errors for the error model.

See it in the example app

  • The spotify-clone client wiring is a concrete, real-world createClient — it builds the client over either a seeded mock transport or a live fetch transport, wires a QueryClient with type:id normalisation, and exposes the bound TanStack read/write APIs.
  • The tested example.test.ts is the canonical usage reference: every read/write snippet on this page is a real typed call there, grouped by describe(...) and run under pnpm test, so it can't rot.

Next

  • Codegen — every flag, the drift check, and multi-server output.
  • Reading — filters, sort, include, sparse fields, pagination.
  • Writing — create/update/delete, response shaping, relationship writes.