Guide

http

If your app talks to a backend that already exists, such as a Laravel, Rails, Adonis or Go API, you need a client that adds the user's login token to every request, tries once to refresh it when it expires, and turns every kind of failure into one predictable error. @kitr/http is that client.

It is ky, re-exported, plus createHttpClient, which adds the token, refresh, CSRF and error handling, and two helpers, httpQuery and httpMutation, that turn endpoints into Query contracts. The app gets one client, bound in the container as http.

It runs in the browser, and on the server when a page renders. When the API is part of this app, use API instead: its client is typed straight from your routes.

This guide follows Acme's order screen at app.acme.com talking to an API at api.acme.com.

When to use it

Use @kitr/http when the API lives outside this app: a separate backend, a vendor's REST API, a service another team owns. You describe each endpoint once with httpQuery or httpMutation, and components read it through queries like any other source.

Use API when you write the endpoints in this app's server/ folder, because its client gets the routes, inputs and outputs from your code with no hand-written types. Use Actions for a server function you call from a form or button without designing a URL.

Install

kitr add http

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

What kitr add does:

  • Adds Query first if the project does not have it, because http requires it.
  • Installs @kitr/http and ky.
  • Adds '@kitr/http' to packages and () => import('@kitr/http/provider') to providers in kitrrc.ts.
  • Adds VITE_API_URL: e.url({ default: 'http://localhost:3333' }) to the public block of createEnv in app/env.ts.
  • Adds VITE_API_URL=http://localhost:3333 to .env.example, and to .env when that file exists.
  • Writes config/http.ts (shown under Configure).
  • Adds a ## kitr: http section to AGENTS.md: the client is app.container.use('http'), endpoints are wrapped with httpQuery/httpMutation, and errors are already KitrError.

kitr add http asks no questions. The app, fullstack, saas, desktop and mobile kits include it. To preview the changes without writing them:

kitr add http --dry-run

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

kitr remove http removes the provider, the packages entry, the ky and @kitr/http dependencies, the AGENTS.md section and config/http.ts. It leaves VITE_API_URL in app/env.ts and .env.example, and lists the files that still import @kitr/http.

Configure

kitr add http writes this file:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  // getToken: () => auth.getToken(), refresh: () => auth.refresh(),
})
Option Default Description
baseUrl none Prefix for every relative path. http.get('orders') with baseUrl: 'https://api.acme.com/v1' requests https://api.acme.com/v1/orders.
getToken none Called before every request with the container. Return a token (or a promise of one) to send it; return null or undefined to send none.
refresh none Called once when a response is 401, with the container. Return true when a new token is ready; the request then retries once with it.

In config/http.ts, getToken and refresh receive the container the client belongs to. On a server that is the request's own container, because the provider builds one http client per request, so getToken: (container) => container.use('auth').getToken() sends the token of the user being served and never another's. In the browser it is the app's container. createHttpClient() called directly takes the same options without the container. | onUnauthorized | none | Called with the KitrError whenever a request finally fails with 401, for example to send the user to sign in. | | authHeader | 'Authorization' | Header that carries the token. With the default the value is Bearer <token>; any other header gets the raw token. | | retry | { limit: 2, methods: ['get', 'put', 'head', 'delete', 'options', 'trace'] } | ky's retry rule. POST and PATCH are never retried, so a write is never sent twice. | | timeout | 10_000 | ky's per-request timeout in milliseconds, or false for none. | | hooks | {} | ky hooks. Yours run after kitr's own beforeRequest, afterResponse and beforeError hooks. | | headers, credentials, fetch, ... | ky's defaults | Every other ky option except prefixUrl passes through unchanged. |

Variable Used for
VITE_API_URL The API's base URL, read through env.VITE_API_URL. Public, so it ships to the browser. Defaults to http://localhost:3333.

To point production at the real API, set the variable where the app is built:

VITE_API_URL=https://api.acme.com/v1 pnpm build

Because config/http.ts is a module, you can compute options in it. This config sends cookies to the API on another origin and adds a header that names the client:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  credentials: 'include',
  headers: { 'x-acme-client': 'web' },
  timeout: 15_000,
})

Load your first endpoint

Describe the endpoint once in the feature's queries.ts. httpQuery turns it into a query contract, and queryFrom turns that into something components and loaders use:

// app/queries/orders.ts
import { app } from '#app/app'
import { createKeys } from 'kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'

export interface Order {
  id: string
  customer: string
  total: number
  status: 'open' | 'paid' | 'shipped'
}

const http = () => app.container.use('http')
export const orderKeys = createKeys('orders')

export const ordersQuery = queryFrom(
  httpQuery<void, Order[]>(http, {
    path: 'orders',
    key: () => orderKeys.list(),
  }),
)

http is a function that returns the client, not the client itself. app/routes.ts imports this file for its loader, and the route table loads before the app boots, when the container has no http binding yet. httpQuery and httpMutation take either form and call the function on each request. Pass the client directly only from code that runs after boot, such as an event handler.

Then show it on a page:

// app/routes.ts
import { ordersQuery } from '#app/queries/orders'

r.page('/orders', {
  loader: ({ context }) => context.queryClient.ensureQueryData(ordersQuery.options()),
  lazy: () => import('./pages/orders'),
})
// app/pages/orders.tsx
import { useSuspenseQuery } from '@kitr/query'
import { ordersQuery } from '#app/queries/orders'

export default function OrdersPage() {
  const { data: orders } = useSuspenseQuery(ordersQuery.options())
  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>
          {order.customer}: ${order.total}
        </li>
      ))}
    </ul>
  )
}

Open /orders. The loader calls GET http://localhost:3333/orders, and the page lists what the API returns, for example Ada: $120 and Grace: $80. If the API is not running, the route's error component shows the KitrError instead.

Read the client

The provider binds one client as http in the container. It is a normal ky instance, so every ky method works on it.

Outside components, such as in queries.ts or a loader, read it from the app:

// app/lib/invoices/download.ts
import { app } from '#app/app'

const http = app.container.use('http')

export async function downloadInvoice(id: string) {
  return http.get(`invoices/${id}/pdf`).blob()
}

In a component, read it with useService from @kitr/react:

// app/components/invoices/download-button.tsx
import { useService } from '@kitr/react'

export function DownloadButton({ id }: { id: string }) {
  const http = useService('http')
  const download = async () => {
    const pdf = await http.get(`invoices/${id}/pdf`).blob()
    window.open(URL.createObjectURL(pdf))
  }
  return (
    <button type="button" onClick={download}>
      Download PDF
    </button>
  )
}

Prefer httpQuery for anything a screen shows: the cache, loading states and refreshes come for free. Use the client directly for one-off calls such as a download.

Call an endpoint directly

The client has a method per HTTP verb. Each returns a response promise with .json(), .text(), .blob() and .arrayBuffer():

// app/lib/orders/api.ts
import { app } from '#app/app'
import type { Order } from '#app/queries/orders'

const http = app.container.use('http')

const orders = await http.get('orders', { searchParams: { status: 'open', page: 2 } }).json<Order[]>()
// GET https://api.acme.com/v1/orders?status=open&page=2

const created = await http.post('orders', { json: { customer: 'Linus', total: 45 } }).json<Order>()
// => { id: 'ord_3', customer: 'Linus', total: 45, status: 'open' }

await http.delete(`orders/${created.id}`)

A leading slash makes no difference: http.get('/orders') and http.get('orders') both request <baseUrl>/orders. An absolute URL such as https://billing.acme.com/rates ignores baseUrl.

For a client with extra defaults, call ky's extend. It keeps the token, refresh and error handling:

// app/lib/billing/client.ts
import { app } from '#app/app'

export const billing = app.container.use('http').extend({
  headers: { 'x-acme-team': 'billing' },
  timeout: 30_000,
})

Turn an endpoint into a query

httpQuery(client, options) returns a query contract that sends a GET. Pass it to queryFrom:

// app/queries/orders.ts
import { app } from '#app/app'
import { createKeys } from 'kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
import type { Order } from './types'

const http = () => app.container.use('http')
export const orderKeys = createKeys('orders')

export const orderQuery = queryFrom(
  httpQuery<string, Order>(http, {
    path: (id) => `orders/${id}`,
    key: (id) => orderKeys.detail(id),
  }),
)

export const ordersQuery = queryFrom(
  httpQuery<{ status?: Order['status']; page: number }, Order[]>(http, {
    path: 'orders',
    key: (filters) => orderKeys.list(filters),
    searchParams: ({ status, page }) => ({ status, page }),
    staleTime: 10_000,
  }),
)
Option Default Description
path required A path relative to baseUrl, or a function of the input that returns one.
key required The cache key for an input. Include every input that changes the result.
searchParams none A function of the input that returns query string values. undefined values are left out, so { status: undefined, page: 1 } sends ?page=1.
schema none A Standard Schema that validates the response and types the result.
options {} ky options for this request only, such as headers or timeout.
staleTime the config's How long this query's data stays fresh, in milliseconds.

The query passes TanStack's abort signal to ky, so leaving the page cancels the request. The contract's source is 'http', which devtools shows.

Validate the response

An API you don't control can change shape. Give httpQuery or httpMutation a schema from zod, valibot or any other Standard Schema library, and a response that fails it becomes a VALIDATION error instead of a crash three components later. The schema also types the result, so you can drop the generic:

// app/queries/invoices.ts
import { app } from '#app/app'
import { createKeys } from 'kitr/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'
import { z } from 'zod'

const http = () => app.container.use('http')
export const invoiceKeys = createKeys('invoices')

const invoice = z.object({
  id: z.string(),
  orderId: z.string(),
  total: z.number(),
  paidAt: z.string().nullable(),
})

export const invoicesQuery = queryFrom(
  httpQuery(http, {
    path: 'invoices',
    key: () => invoiceKeys.list(),
    schema: z.array(invoice),
  }),
)

When the API sends { "total": "80.00" } where a number is expected, the query fails with:

// => KitrError { code: 'VALIDATION', status: 422, message: 'Validation failed', fieldErrors: { '0.total': ['Invalid input: expected number, received string'] } }

The key is the path to the bad value (0.total: the first invoice's total). The message comes from your schema library; this one is zod 4's.

Turn an endpoint into a mutation

httpMutation(client, options) returns a mutation contract. Pass it to mutationFrom and run it with useMutation. Declare effects so every screen showing the changed data updates:

// app/queries/orders.ts
import { app } from '#app/app'
import { httpMutation } from '@kitr/http'
import { mutationFrom } from '@kitr/query'
import { orderKeys } from './keys'
import type { Order } from './types'

const http = () => app.container.use('http')

export const createOrder = mutationFrom(
  httpMutation<{ customer: string; total: number }, Order>(http, {
    path: 'orders',
    effects: { invalidate: [orderKeys.lists()] },
  }),
)

export const shipOrder = mutationFrom(
  httpMutation<{ id: string; carrier: string }, Order>(http, {
    method: 'post',
    path: (input) => `orders/${input.id}/ship`,
    body: (input) => ({ carrier: input.carrier }),
    effects: (order) => ({
      set: [{ key: orderKeys.detail(order.id), data: order }],
      invalidate: [orderKeys.lists()],
    }),
  }),
)
// app/components/orders/ship-button.tsx
import { useMutation } from '@kitr/query'
import { shipOrder } from '#app/queries/orders'

export function ShipButton({ id }: { id: string }) {
  const ship = useMutation(shipOrder)
  return (
    <button type="button" disabled={ship.isPending} onClick={() => ship.mutate({ id, carrier: 'UPS' })}>
      Ship
    </button>
  )
}
Option Default Description
method 'post' One of 'get', 'post', 'put', 'patch', 'delete'.
path required A path, or a function of the input that returns one.
body the input A function that builds the JSON body from the input. Without it, the whole input is the body, except for get and delete, which send none.
schema none A Standard Schema that validates the response and types the result.
effects none Cache effects after success: an object, or a function of (result, input). See Query.
options {} ky options for this request only.

Delete and update records

delete sends no body by default, and a 204 No Content response resolves to undefined, so the result type is void:

// app/queries/projects.ts
import { app } from '#app/app'
import { createKeys } from 'kitr/app'
import { httpMutation } from '@kitr/http'
import { mutationFrom } from '@kitr/query'

const http = () => app.container.use('http')
export const projectKeys = createKeys('projects')

export const deleteProject = mutationFrom(
  httpMutation<{ id: string }, void>(http, {
    method: 'delete',
    path: ({ id }) => `projects/${id}`,
    effects: (_result, { id }) => ({
      remove: [projectKeys.detail(id)],
      invalidate: [projectKeys.lists()],
    }),
  }),
)

export const renameProject = mutationFrom(
  httpMutation<{ id: string; name: string }, { id: string; name: string }>(http, {
    method: 'patch',
    path: ({ id }) => `projects/${id}`,
    body: ({ name }) => ({ name }),
    effects: (project) => ({ set: [{ key: projectKeys.detail(project.id), data: project }] }),
  }),
)

renameProject sends PATCH /projects/prj_1 with the body {"name":"Website"}; the id stays in the URL because body leaves it out.

Upload a file

httpMutation always sends JSON. For a file, call the client with a FormData body and let the browser set the multipart headers:

// app/lib/invoices/import.ts
import { app } from '#app/app'
import { mutationFrom } from '@kitr/query'
import { invoiceKeys } from '#app/queries/invoices'

const http = app.container.use('http')

export const importInvoices = mutationFrom({
  mutate: async (file: File) => {
    const form = new FormData()
    form.append('file', file)
    return http.post('invoices/import', { body: form, timeout: 60_000 }).json<{ imported: number }>()
  },
  effects: { invalidate: [invoiceKeys.lists()] },
})
// => { imported: 42 }

Don't set content-type yourself for FormData: the browser adds the boundary the server needs to parse the body. For uploads to your own server with progress and size limits, see Uploads.

Send the login token

getToken runs before every request. When it returns a token, the client sends Authorization: Bearer <token>. With Auth's token adapter, keep the adapter in its own module and point the client at it:

// app/auth/adapter.ts
import { localStorageTokens, tokenAdapter } from '@kitr/auth'
import { env } from '#app/env'

export const authAdapter = tokenAdapter<{ id: string; email: string }>({
  storage: localStorageTokens('acme:token'),
  refreshStorage: localStorageTokens('acme:refresh'),
  fetchUser: async (token) => {
    const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } })
    return res.ok ? res.json() : null
  },
  refresh: async (refreshToken) => {
    const res = await fetch(`${env.VITE_API_URL}/auth/refresh`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    })
    return res.ok ? res.json() : null
  },
})
// config/http.ts
import { defineConfig } from '@kitr/http'
import { authAdapter } from '#app/auth/adapter'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  getToken: () => authAdapter.getToken?.(),
  refresh: async () => (await authAdapter.refresh?.()) ?? false,
  onUnauthorized: () => window.location.assign('/sign-in'),
})

Components never touch the token. When Ada signs in, the adapter stores her token, and the next request carries it.

An API that expects a key in its own header gets the raw token. This config sends x-api-key: <token> and no Authorization header:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  authHeader: 'x-api-key',
  getToken: () => localStorage.getItem('acme:api-key'),
})

Warning VITE_ variables are compiled into the browser bundle, where anyone can read them. Never put a secret API key in one. Fetch per-user tokens at sign-in instead.

Refresh an expired token

When a response is 401 and refresh is set, the client calls refresh() once. If it returns true, the client reads the token again with getToken() and retries the request once, marked with an x-kitr-retried: 1 header. If it returns false, or the retry is also 401, the request fails with an UNAUTHORIZED error and onUnauthorized runs.

Several requests that fail at the same moment share one refresh() call. When Grace's dashboard loads orders, invoices and projects together after her token expired, the client refreshes once and retries all three with the new token.

onUnauthorized runs for every request that ends in 401, with or without refresh. Use it to sign the user out or send them to the sign-in page, not to show a message per request:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  onUnauthorized: (error) => {
    console.warn('Session ended:', error.message)
    window.location.assign(`/sign-in?next=${encodeURIComponent(window.location.pathname)}`)
  },
})

Send the CSRF token

On every request except GET, HEAD and OPTIONS, the client reads the kitr-csrf cookie and sends its value as the x-csrf-token header. That is the double-submit check of Shield, so writes to a same-site kitr server pass its CSRF check with no code. A request that already has an x-csrf-token header keeps it. On the server there is no document, so no header is added.

Retry and time out

Idempotent requests (GET, PUT, HEAD, DELETE, OPTIONS, TRACE) retry up to twice on ky's retryable statuses, such as 408, 429, 500, 502, 503 and 504, and on network errors, with ky's backoff between attempts. POST and PATCH are never retried, because sending a payment or an order twice is worse than showing an error.

Queries add their own retries on top: Query retries failed queries twice unless the status is 4xx. For a screen that must fail fast, turn off one layer:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  retry: 0, // Query's retries are enough
  timeout: 5_000,
})

For one slow endpoint, pass options to the contract instead of changing every request:

// app/queries/reports.ts
import { app } from '#app/app'
import { httpQuery } from '@kitr/http'
import { queryFrom } from '@kitr/query'

const http = () => app.container.use('http')

export const yearlyReportQuery = queryFrom(
  httpQuery<number, { year: number; revenue: number }>(http, {
    path: (year) => `reports/${year}`,
    key: (year) => ['reports', year],
    options: { timeout: 60_000, retry: 0 },
  }),
)

Add hooks

ky hooks run around every request. Pass them in config/http.ts; yours run after kitr's, so beforeRequest sees the token already set and beforeError receives the normalised KitrError:

// config/http.ts
import { defineConfig } from '@kitr/http'
import { env } from '#app/env'

export default defineConfig({
  baseUrl: env.VITE_API_URL,
  hooks: {
    beforeRequest: [
      ({ request }) => {
        request.headers.set('x-request-id', crypto.randomUUID())
      },
    ],
    afterResponse: [
      ({ response }) => {
        const remaining = response.headers.get('x-ratelimit-remaining')
        if (remaining === '0') console.warn('Acme API rate limit reached')
      },
    ],
  },
})

Handle errors

Every failure becomes a KitrError with code, status, message and, when the API sends them, fieldErrors and data. Components, forms and queries check code, never the raw response:

// app/lib/orders/place-order.ts
import { app } from '#app/app'
import { isKitrError } from 'kitr/app'

const http = app.container.use('http')

try {
  await http.post('orders', { json: { customer: 'Ada', total: -5 } })
} catch (error) {
  if (isKitrError(error) && error.code === 'VALIDATION') console.log(error.fieldErrors)
  // => { total: ['Must be positive'] }
}

The client reads the error body in this order:

The API responds with The KitrError has
kitr's own shape { code, message, status, fieldErrors? } Those fields as-is.
{ errors: [{ field, message }] } (Adonis, VineJS) fieldErrors keyed by field, or path; entries without either go under _root.
{ message: '...' } That message.
{ error: { message: '...' } } or { error: '...' } That message.
No JSON body, such as an HTML error page The status's code and a message from it, such as 'Not found'.

The full parsed body is kept in error.data. For example, a 409 with { "message": "Invoice already paid" } becomes:

// => KitrError { code: 'CONFLICT', status: 409, message: 'Invoice already paid', data: { message: 'Invoice already paid' } }

A 422 whose fieldErrors reach a Forms <Form> shows each message under its input, with no code in the component.

Commands

Command What it does
kitr make:queries <name> Writes app/queries/<name>.ts with keys, a list query, a detail query and an update mutation over @kitr/http.
kitr make:queries orders

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

It writes app/queries/orders.ts:

// app/queries/orders.ts
import { createKeys } from 'kitr/app'
import { mutationFrom, queryFrom } from '@kitr/query'
import { httpMutation, httpQuery } from '@kitr/http'
import { app } from '#app/app'

const http = () => app.container.use('http')
export const ordersKeys = createKeys('orders')

export interface Orders {
  id: string
  name: string
}

export const ordersListQuery = queryFrom(
  httpQuery<void, Orders[]>(http, { path: 'orders', key: () => ordersKeys.list() }),
)

export const ordersQuery = queryFrom(
  httpQuery<string, Orders>(http, { path: (id) => `orders/${id}`, key: (id) => ordersKeys.detail(id) }),
)

export const updateOrders = mutationFrom(
  httpMutation<Partial<Orders> & { id: string }, Orders>(http, {
    method: 'patch',
    path: (input) => `orders/${input.id}`,
    effects: (result) => ({ set: [{ key: ordersKeys.detail(result.id), data: result }], invalidate: [ordersKeys.lists()] }),
  }),
)

Names come from the argument as given, so pass the singular (kitr make:queries order) if you want Order and orderQuery. Pass --force to overwrite an existing file. To change the template for your project, run kitr eject stub queries and edit stubs/queries.ts.

Errors

Code When What to do
BAD_REQUEST 400, or any 4xx not listed below. Fix the request; the message usually says what is wrong.
UNAUTHORIZED 401 after refresh failed or was not set. onUnauthorized runs. Sign the user in again.
FORBIDDEN 403. Hide the action, or ask the API owner for access.
NOT_FOUND 404. Show a not-found state; the record was deleted or the path is wrong.
TIMEOUT 408 from the server. Retry later.
CONFLICT 409. Reload the record and let the user decide.
PAYLOAD_TOO_LARGE 413. Send a smaller body or file.
VALIDATION 422, or a response that fails the contract's schema. Show fieldErrors next to the inputs; for a schema failure, update the schema or the API.
RATE_LIMITED 429. Back off; ky already retried idempotent requests.
INTERNAL 500 or any other 5xx; also a network failure or a client-side timeout. Show a retry button. The message names the cause, such as Request timed out: GET https://api.acme.com/v1/orders.
UNAVAILABLE 503. Show a maintenance notice and retry later.

normaliseHttpError(error) performs this mapping on any error, if you call ky outside the client:

// app/lib/errors.ts
import { normaliseHttpError } from '@kitr/http'

export async function describeError(error: unknown) {
  const kitrError = await normaliseHttpError(error)
  return `${kitrError.code} (${kitrError.status}): ${kitrError.message}`
}
// describeError(new TypeError('Failed to fetch')) => 'NETWORK (0): Network request failed'

Testing

createHttpClient takes ky's fetch option, so a test passes a fake fetch and never touches the network. The fake sees the real Request, with the URL, headers and body the client built:

// app/queries/orders.test.ts
import { createKeys } from 'kitr/app'
import { createHttpClient, httpMutation, httpQuery } from '@kitr/http'
import { describe, expect, it, vi } from 'vitest'

interface Order {
  id: string
  customer: string
  status: 'open' | 'shipped'
}

const orderKeys = createKeys('orders')

function fakeApi(handler: (request: Request) => Response) {
  const requests: Request[] = []
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
    const request = input instanceof Request ? input : new Request(input, init)
    requests.push(request.clone())
    return handler(request)
  })
  return { fetch: fetch as unknown as typeof globalThis.fetch, requests }
}

describe('orders endpoints', () => {
  it('lists open orders with the token and filters', async () => {
    const api = fakeApi(() => Response.json([{ id: 'ord_1', customer: 'Ada', status: 'open' }]))
    const http = createHttpClient({ baseUrl: 'https://api.acme.com/v1', getToken: () => 'tok_ada', fetch: api.fetch })
    const orders = httpQuery<{ status?: string }, Order[]>(http, {
      path: 'orders',
      key: (filters) => orderKeys.list(filters),
      searchParams: ({ status }) => ({ status }),
    })

    expect(await orders.fetch({ status: 'open' }, {})).toEqual([{ id: 'ord_1', customer: 'Ada', status: 'open' }])
    expect(api.requests[0]!.url).toBe('https://api.acme.com/v1/orders?status=open')
    expect(api.requests[0]!.headers.get('authorization')).toBe('Bearer tok_ada')
  })

  it('turns a 422 into field errors', async () => {
    const api = fakeApi(() =>
      Response.json({ errors: [{ field: 'customer', message: 'Required' }] }, { status: 422 }),
    )
    const http = createHttpClient({ baseUrl: 'https://api.acme.com/v1', fetch: api.fetch })
    const createOrder = httpMutation<{ customer: string }, Order>(http, { path: 'orders' })

    await expect(createOrder.mutate({ customer: '' })).rejects.toMatchObject({
      code: 'VALIDATION',
      status: 422,
      fieldErrors: { customer: ['Required'] },
    })
    expect(await api.requests[0]!.json()).toEqual({ customer: '' })
  })
})

To test with the app's own provider and config, boot a test app and pass the fake fetch in the http config:

// app/lib/orders/provider.test.ts
import { httpProvider } from '@kitr/http'
import { createTestApp } from '@kitr/testing'
import { expect, it } from 'vitest'

it('binds a client with the base URL', async () => {
  const fetch = async (input: RequestInfo | URL) =>
    Response.json({ url: input instanceof Request ? input.url : String(input) })
  const app = await createTestApp({
    providers: [httpProvider],
    config: { http: { baseUrl: 'https://api.acme.com/v1', fetch: fetch as typeof globalThis.fetch } },
  })

  expect(await app.container.use('http').get('teams').json()).toEqual({
    url: 'https://api.acme.com/v1/teams',
  })
})

To fake the API for a whole app, in tests and in the browser during development, use Mocks: its handlers answer every request the client makes, with no change to config/http.ts.

Devtools

With Devtools installed, every request in development is logged to the Log panel under http, for example GET /orders → 200 (84ms). The entry carries the full URL, the status and the time in milliseconds. Responses from 400 to 499 log as warnings and 500 and above as errors, so a failing endpoint stands out.

Next steps

  • API reference
  • ky docs: every option and method works on the http client.
  • Query: caching, mutations and effects for the contracts this package builds.
  • Auth: sign-in and the token adapter that feeds getToken and refresh.
  • Mocks: fake the API in tests and development.
  • Loading and saving data: how HTTP fits beside API routes, actions and realtime.