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 httpFirst 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
httprequires it. - Installs
@kitr/httpandky. - Adds
'@kitr/http'topackagesand() => import('@kitr/http/provider')toprovidersinkitrrc.ts. - Adds
VITE_API_URL: e.url({ default: 'http://localhost:3333' })to thepublicblock ofcreateEnvinapp/env.ts. - Adds
VITE_API_URL=http://localhost:3333to.env.example, and to.envwhen that file exists. - Writes
config/http.ts(shown under Configure). - Adds a
## kitr: httpsection toAGENTS.md: the client isapp.container.use('http'), endpoints are wrapped withhttpQuery/httpMutation, and errors are alreadyKitrError.
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-runFirst 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 ordersFirst 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
httpclient. - Query: caching, mutations and effects for the contracts this package builds.
- Auth: sign-in and the token adapter that feeds
getTokenandrefresh. - Mocks: fake the API in tests and development.
- Loading and saving data: how HTTP fits beside API routes, actions and realtime.