No runtime dependencies
Fetchja uses the platform fetch API and stays around 6 KB minified. There is no adapter stack to keep patched.
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.
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) included data,
query strings, and relationship hydration so your app can stay close to
plain objects.
Fetchja uses the platform fetch API and stays around 6 KB minified. There is no adapter stack to keep patched.
The package is written in TypeScript and ships its own definitions, so autocomplete works without an extra @types package.
Send and receive plain objects. Fetchja handles resources, relationships, included data, and query strings — and drops nothing the specification defines.
Responses are deserialized with prototype-pollution guards before your app touches the payload.
Bring your own headers, query formatter, casing strategy, pluralizer, fetch implementation, and retry hook.
Nested relationship objects serialize correctly on write and are merged from included data on read.
fetch
exists: Node 18+, Deno, Bun, or any modern browser.
npm install fetchja get for reads. Responses expose
data, meta, status, and
headers as plain values.
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', ... } Fetchja. JSON:API
headers and the built-in query serializer are applied by default.
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. |
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.
const api = new Fetchja({
baseURL: 'https://api.example.com',
fetch: (url, init) => myCustomFetch(url, init)
})
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.
import pluralize from 'pluralize'
const api = new Fetchja({
baseURL: 'https://api.example.com',
pluralize
}) const api = new Fetchja({
baseURL: 'https://api.example.com',
pluralize: false
}) 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 |
{
"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
} {
"data": {
"id": "1",
"type": "articles",
"title": "Hello world",
"author": { "id": "9", "name": "Ada Lovelace" }
},
"status": 200
} {
"data": {
"id": "3",
"type": "articles",
"title": "Hello"
},
"status": 201
} {
"data": {
"id": "1",
"type": "articles",
"title": "New title"
},
"status": 200
} {
"data": null,
"status": 204
} type and id inside your
data. Fetchja moves it into the JSON:API relationship shape and keeps the
included resource payload aligned.
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.
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.
const { data } = await api.get(
'articles/1',
{ params: { include: 'author' } }
)
console.log(data.author.name)
// comes from included 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.
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.
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.
await api.create(
'article',
{ title: 'Hello world' },
{ document: { meta: { source: 'cli' } } }
) params object. Arrays become
comma-separated values, nested objects become bracket keys, and the final
URL follows JSON:API 1.1 conventions.
await api.get('articles', {
params: {
include: ['author', 'comments'],
fields: { articles: ['title', 'body'] },
filter: { published: true },
sort: ['-createdAt'],
page: { number: 1, size: 10 }
}
})
Some servers prefer repeated keys like
include[]=author&include[]=comments. Replace
the formatter once at construction and keep call sites unchanged.
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
}) FetchjaError. The error keeps
the HTTP status, status text, raw Response, and
JSON:API errors array.
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)
}
} 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.
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 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.
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.
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.
const author = {
type: 'articles',
id: '13',
relationship: 'author'
}
await api.atomic(op => [
op.update(author, { type: 'people', id: '9' }),
op.update(author, null)
]) An extension is a plain object. It installs methods on the client and hooks into every request, response, and error, in registration order.
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)
} 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')
})
} 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']
})
}
})
} @types package.
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
} 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.
JSON:API is a specification for building JSON APIs with consistent resources, relationships, included data, filtering, sorting, fields, and pagination.
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.
Anywhere fetch exists: Node 18+, Deno, Bun, and modern browsers. The package is ESM-only.
Yes. The default formatter follows JSON:API 1.1, and you can pass queryFormatter when a server expects a different shape.
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.
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.