zero-dependency

The easiest way to use JSON:API.

Say goodbye to complex serializers. Fetchja turns plain objects into JSON:API requests and auto-resolves relationships, all while staying tiny and built on native fetch.

$ npm install fetchja
~6 KB
minified
0
dependencies
100%
typed
articles.ts
import Fetchja from 'fetchja'

const api = new Fetchja({
  baseURL: 'https://api.example.com'
})

const { data, meta } = await api.get('articles')

const { data: article } = await api.get('articles/1', {
  params: { include: 'author' }
})

console.log(article.author.name)
Why Fetchja

Less protocol plumbing. More application code.

JSON:API gives teams a useful contract, but the wire format is verbose. Fetchja handles serialization, included data, query strings, and relationship hydration so your app can stay close to plain objects.

No runtime dependencies

Fetchja uses the platform fetch API and stays around 6 KB minified. There is no adapter stack to keep patched.

Types included

The package is written in TypeScript and ships its own definitions, so autocomplete works without an extra @types package.

Less JSON:API boilerplate

Send and receive plain objects. Fetchja handles resources, relationships, included data, and query strings — and drops nothing the specification defines.

Safer response parsing

Responses are deserialized with prototype-pollution guards before your app touches the payload.

Configurable edges

Bring your own headers, query formatter, casing strategy, pluralizer, fetch implementation, and retry hook.

Relationships resolved

Nested relationship objects serialize correctly on write and are merged from included data on read.

Install

One package. No adapter stack.

Fetchja only needs an environment where fetch exists: Node 18+, Deno, Bun, or any modern browser.
bash - install
$ npm install fetchja
Quick start

Create a client once, then call resources by name.

Use get for reads. Responses expose data, meta, status, and headers as plain values.
quick-start.ts
import Fetchja from 'fetchja'

const api = new Fetchja({
  baseURL: 'https://api.example.com'
})

const { data, meta } = await api.get('articles')
const { data: article } = await api.get('articles/1')

console.log(article)
// { type: 'articles', id: '1', title: 'Hello world', ... }
Options

Configure behavior at the client boundary.

Set options when you create Fetchja. JSON:API headers and the built-in query serializer are applied by default.
client.ts
const api = new Fetchja({
  baseURL: 'https://api.example.com',
  headers: {},
  fetch: globalThis.fetch,
  queryFormatter: undefined,
  resourceCase: 'none',
  typeCase: 'camel',
  pluralize: true,
  onResponseError: undefined
})
Option Type Default Behavior
baseURL string required The base URL used to build every request.
headers Record<string, string> JSON:API headers Headers merged into every request.
fetch typeof fetch global fetch A custom fetch function for tokens, logging, or timeouts.
queryFormatter (params) => string | URLSearchParams built-in Turns params into a JSON:API query string.
resourceCase camel | kebab | snake | none 'none' Controls resource names in URL paths.
typeCase camel | kebab | snake | none 'camel' Controls type names when request bodies are serialized.
pluralize boolean | ((word) => string) true Pluralizes resource names, or accepts your own pluralizer.
onResponseError (response) => Response | void optional Runs before Fetchja throws and can replay failed requests.

# Bring your own fetch

Provide any function with the standard fetch signature. That keeps auth tokens, request tracing, timeouts, and tests in the same place as the rest of your app.

custom-fetch.ts
const api = new Fetchja({
  baseURL: 'https://api.example.com',
  fetch: (url, init) => myCustomFetch(url, init)
})

# Replace the pluralizer

Fetchja pluralizes resource names by default — including common irregulars like person to people — and avoids simple double-plurals. For anything fancier, inject a library such as pluralize , or set pluralize: false and use exact resource names.

custom-plural.ts
import pluralize from 'pluralize'

const api = new Fetchja({
  baseURL: 'https://api.example.com',
  pluralize
})
no-plural.ts
const api = new Fetchja({
  baseURL: 'https://api.example.com',
  pluralize: false
})
Methods

Four verbs, with aliases that read naturally.

Use get, post, patch, and delete, or the aliases fetch, create, update, and remove.
Method Alias Signature HTTP
get fetch get(model, options?) GET
post create post(model, body, options?) POST
patch update patch(model, body, options?) PATCH
delete remove delete(model, id, options?) DELETE
response
{
  "data": [
    { "id": "1", "type": "articles", "title": "Hello world" },
    { "id": "2", "type": "articles", "title": "Second post" }
  ],
  "meta": { "total": 42 },
  "links": { "next": "/articles?page[offset]=2" },
  "status": 200
}
Array of resolved resource objects
{
  "data": {
    "id": "1",
    "type": "articles",
    "title": "Hello world",
    "author": { "id": "9", "name": "Ada Lovelace" }
  },
  "status": 200
}
Single resource object with resolved relationships
{
  "data": {
    "id": "3",
    "type": "articles",
    "title": "Hello"
  },
  "status": 201
}
Server-assigned id comes back in data
{
  "data": {
    "id": "1",
    "type": "articles",
    "title": "New title"
  },
  "status": 200
}
Updated fields reflected immediately
{
  "data": null,
  "status": 204
}
No body is returned for 204 responses
Relationships & included

Write nested objects. Read hydrated objects.

To send a relationship, put an object or list with type and id inside your data. Fetchja moves it into the JSON:API relationship shape and keeps the included resource payload aligned.
create-with-relationships.ts
await api.create('article', {
  title: 'Hello world',
  author: { type: 'people', id: '9' },
  tags: [
    { type: 'tags', id: '1' },
    { type: 'tags', id: '2' }
  ]
})

To clear a relationship, send it empty: an empty list for a to-many, and an id of null for a to-one. An object with no id key still throws, so a forgotten key never clears a relationship by accident.

clear-relationships.ts
await api.update('article', {
  id: '1',
  author: { id: null }, // data: null
  tags: [] // data: []
})

When data comes back, resources from included are merged into data. You can access relationships like normal nested objects.

read-relationship.ts
const { data } = await api.get(
  'articles/1',
  { params: { include: 'author' } }
)

console.log(data.author.name)
// comes from included
Meta & links

Flat where it helps. Complete where it counts.

A resource carries more than attributes: meta, links, a client-generated lid, and the meta and links of every relationship. None of it fits the flat shape, so Fetchja keeps all of it under a single $ key.
read-metadata.ts
const { data, links } = await api.get(
  'articles/1'
)

data.title        // an attribute, flat as always
data.$.meta       // the resource meta
data.$.links.self // the resource links

data.$.relationships.author.meta
data.$.relationships.author.links.related

links.next        // document links, including pagination

JSON:API forbids $ in member names, so it can never clash with one of your fields. An attribute named meta stays exactly where you expect it, and the resource's own meta sits right next to it. Resources that carry nothing extra have no $ at all.

Writing works the same way. Whatever you put in $ is written straight into the outgoing resource object, so a resource read from the server can be sent back without losing anything.

write-metadata.ts
await api.create('article', {
  title: 'Hello world',
  author: { type: 'people', id: '9' },
  $: {
    lid: 'draft-1',
    meta: { draft: true },
    relationships: {
      author: { meta: { role: 'primary' } }
    }
  }
})

For the document itself, pass document with the request.

document-members.ts
await api.create(
  'article',
  { title: 'Hello world' },
  { document: { meta: { source: 'cli' } } }
)
Query parameters

Plain params in. JSON:API query strings out.

Pass a params object. Arrays become comma-separated values, nested objects become bracket keys, and the final URL follows JSON:API 1.1 conventions.
you write
request.ts
await api.get('articles', {
  params: {
    include: ['author', 'comments'],
    fields: { articles: ['title', 'body'] },
    filter: { published: true },
    sort: ['-createdAt'],
    page: { number: 1, size: 10 }
  }
})
fetchja sends
GET api.example.com/articles?include=author,comments&fields[articles]=title,body&filter[published]=true&page[size]=10

# Bring your server's query format

Some servers prefer repeated keys like include[]=author&include[]=comments. Replace the formatter once at construction and keep call sites unchanged.

query-formatter.ts
import Fetchja from 'fetchja'

function bracketQueryFormatter (params) {
  const search = new URLSearchParams()

  for (const key in params) {
    const value = params[key]

    if (Array.isArray(value)) {
      value.forEach(item => search.append(`${key}[]`, String(item)))
    } else {
      search.append(key, String(value))
    }
  }

  return search
}

const api = new Fetchja({
  baseURL: 'https://api.example.com',
  queryFormatter: bracketQueryFormatter
})
Errors & retries

Failures keep the context you need.

Failed requests throw FetchjaError. The error keeps the HTTP status, status text, raw Response, and JSON:API errors array.
errors.ts
import Fetchja, { FetchjaError } from 'fetchja'

try {
  await api.get('articles/999')
} catch (error) {
  if (error instanceof FetchjaError) {
    console.log(error.status)
    console.log(error.statusText)
    console.log(error.errors)
    console.log(error.document)
    console.log(error.response)
  }
}

# Retry before the error is thrown

onResponseError runs on a failing response. The response includes replayRequest(), so token refresh logic can retry the original request once and return the new response.

retry.ts
const api = new Fetchja({
  baseURL: 'https://api.example.com',
  headers: { Authorization: `Bearer ${getToken()}` },
  onResponseError: async response => {
    if (response.status === 401) {
      api.headers.Authorization = `Bearer ${await refreshToken()}`

      return response.replayRequest()
    }
  }
})
Extensions

The spec keeps going. So does Fetchja.

The extensions option registers JSON:API extensions. The first official one is Atomic Operations : an ordered, all-or-nothing batch of writes sent to a single endpoint.
atomic.ts
import Fetchja from 'fetchja'
import { AtomicOperations } from 'fetchja/atomic'

const api = new Fetchja({
  baseURL: 'https://api.example.com',
  extensions: [AtomicOperations]
})

const { results } = await api.atomic(op => [
  op.add('author', { lid: 'a1', name: 'dgeb' }),
  op.add('article', {
    title: 'JSON API paints my bikeshed!',
    author: { type: 'authors', lid: 'a1' }
  }),
  op.update('article', { id: '13', title: 'To TDD or Not' }),
  op.remove('article', '9')
])

results
// [{ type: 'authors', id: '9', name: 'dgeb' }, ...]

The batch is posted to operations, and only that request carries the extension media type. Operations are built with add, update, remove, and raw. A lid links resources that do not exist yet. results comes back positional — one entry per operation, in the order you sent them.

# Target a relationship

Pass a ref object instead of a model name to write a relationship directly. null clears a to-one, and an array adds or drops to-many members.

relationship.ts
const author = {
  type: 'articles',
  id: '13',
  relationship: 'author'
}

await api.atomic(op => [
  op.update(author, { type: 'people', id: '9' }),
  op.update(author, null)
])

# Write your own

An extension is a plain object. It installs methods on the client and hooks into every request, response, and error, in registration order.

extension.ts
const timing = {
  name: 'timing',

  methods: api => ({
    ping: () => api.get('health')
  }),

  onRequest: context => {
    context.headers['X-Sent-At'] = String(Date.now())
  },

  onResponse: (payload, response) => payload,
  onError: error => console.warn(error.status)
}
TanStack Query

Drop Fetchja behind your existing cache layer.

Fetchja works cleanly with TanStack Query . Because failed requests throw, query and mutation error states work through the normal TanStack Query flow.
use-articles.ts
import {
  useQuery
} from '@tanstack/react-query'
import Fetchja from 'fetchja'

const api = new Fetchja({
  baseURL: 'https://api.example.com'
})

function useArticles () {
  return useQuery({
    queryKey: ['articles'],
    queryFn: () => api.get('articles')
  })
}
use-create-article.ts
import {
  useMutation,
  useQueryClient
} from '@tanstack/react-query'

function useCreateArticle () {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: article => api.create('article', article),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['articles']
      })
    }
  })
}
TypeScript

Types ship with the package.

Fetchja is written in TypeScript and exposes its own definitions. You do not need an extra @types package.
typed.ts
import Fetchja, {
  type FetchjaOptions,
  type ResourceEnvelope,
  FetchjaError
} from 'fetchja'

const options: FetchjaOptions = {
  baseURL: 'https://api.example.com',
  resourceCase: 'kebab'
}

const api = new Fetchja(options)

interface Article {
  type: 'articles'
  id: string
  title: string
  $?: ResourceEnvelope
}
FAQ

Questions, answered.

What is Fetchja?

Fetchja is a small JSON:API client built on native fetch. You work with plain objects while it serializes requests and resolves relationships from responses.

What is JSON:API?

JSON:API is a specification for building JSON APIs with consistent resources, relationships, included data, filtering, sorting, fields, and pagination.

How is Fetchja different from axios or kitsu?

Axios is a general HTTP client. Fetchja is focused on JSON:API. It was inspired by kitsu, but uses native fetch, has no runtime dependencies, and ships its own TypeScript types.

Where does it run?

Anywhere fetch exists: Node 18+, Deno, Bun, and modern browsers. The package is ESM-only.

Can I customize query strings?

Yes. The default formatter follows JSON:API 1.1, and you can pass queryFormatter when a server expects a different shape.

Where do meta and links end up?

On the resource, under a reserved $ key: $.meta, $.links, $.lid, and the meta and links of each relationship. JSON:API forbids $ in member names, so it never clashes with one of your fields. Document-level meta, links, and jsonapi sit next to data.

How do relationships work?

On write, nested objects with type and id are moved into JSON:API relationships and included data. On read, included resources are merged back into data so they behave like normal nested objects.