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
~5 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 5 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.

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.
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 },
  "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' }
  ]
})

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
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
})
Custom fetch

Use the fetch implementation your app already trusts.

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)
})
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.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()
    }
  }
})
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,
  FetchjaError
} from 'fetchja'

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

const api = new Fetchja(options)
Plurals

Use the built-in pluralizer or replace it.

Fetchja pluralizes resource names by default and avoids simple double-plurals. For domain-specific words, pass a function or set pluralize: false and use exact resource names.

# Bring a specialized library

For irregular words like person to people, inject a library such as pluralize .

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
})
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.

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.