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.

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, another team's service. Describe each endpoint once with httpQuery or httpMutation, and components read it through queries.

Use API for endpoints you write in this app's server/ folder (its client is typed from your routes), and Actions for a server function called from a form or button.

Install

kitr add http

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

Everything kitr add changes

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. To see the changes without writing them:

kitr add http --dry-run

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

What kitr remove does

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.

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

getToken and refresh receive the container the client belongs to. On a server that is the request's own container, so getToken: (container) => container.use('auth').getToken() sends the token of the user being served and never another's.

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

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

// 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 makes a query contract and queryFrom makes it usable by components and loaders:

// app/queries/orders.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/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, because the route table loads before the app boots, when the container has no http binding yet. Pass the client directly only from code that runs after boot.

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, such as Ada: $120. If the API is down, the route's error component shows the KitrError.

Read the client

The provider binds one client as http in the container. It is a normal ky instance. Outside components, 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; 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, and an absolute URL 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/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.

Validate the response

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

// app/queries/invoices.ts
import { app } from '#app/app'
import { createKeys } from '@kitr/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.

Turn an endpoint into a mutation

httpMutation(client, options) returns a mutation contract for mutationFrom and 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 Builds the JSON body from the input. By default the input is the body (none for get and delete).
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/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 (for uploads with progress and size limits, see Uploads):

// 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 }

Send the login token

getToken runs before every request; a returned token is sent as 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 next request carries it. An API that expects its own header gets the raw token; this config sends x-api-key: <token>:

// 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'),
})

Refresh an expired token

On a 401 with refresh set, the client calls refresh() once. If it returns true, it reads the token again with getToken() and retries the request once. If it returns false, or the retry is also 401, the request fails with UNAUTHORIZED and onUnauthorized runs.

Requests that fail together share one refresh() call: Grace's dashboard loading three lists after her token expired refreshes once. onUnauthorized runs for every request that ends in 401; use it to sign the user out or go to sign-in:

// 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 sends the kitr-csrf cookie as the x-csrf-token header. That satisfies Shield's double-submit check, so writes to a same-site kitr server need no code.

Retry and time out

Idempotent requests (GET, PUT, HEAD, DELETE, OPTIONS, TRACE) retry up to twice on retryable statuses (408, 429, 5xx) and network errors. POST and PATCH are never retried, so a payment is never sent twice. Query adds its own retries on top; 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 and beforeError the 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/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 parsed body is kept in error.data. A 409 with { "message": "Invoice already paid" } becomes:

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

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/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/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()] }),
  }),
)

Pass the singular (kitr make:queries order) for Order and orderQuery. --force overwrites an existing file; kitr eject stub queries lets you edit the template.

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) applies this mapping to errors from ky calls made 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 that sees the real Request and never touches the network:

// app/queries/orders.test.ts
import { createKeys } from '@kitr/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 use the app's own provider, 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, use Mocks.

Devtools

With Devtools installed, each development request is logged under http, such as GET /orders → 200 (84ms); 4xx log as warnings and 5xx as errors.

Next steps