← stitchkitDocumentation

Typed client

From a contract, createClient builds a fully-typed client — one method per endpoint, arguments and result inferred from the schemas. There is no codegen step: the types come straight from the contract import.

createHttpClient

The HTTP client is the transport. It wraps ky and adds cookie auth, SSR cookie forwarding, error parsing into ApiError, a 401 → unauthorized event stream and safe transport retry.

import { createHttpClient } from 'stitchkit'

const http = createHttpClient({ baseUrl: '/api' })

HttpClientConfig

Field Default Purpose
baseUrl URL prefix for every request
timeout 30000 request timeout, ms
credentials 'include' fetch credentials mode
retry 2× GET, network errors only transport retry policy
headers extra headers — an object, or a function re-run per request
authEndpoints ['auth/'] paths that should not emit unauthorized on 401
parseError built-in map an error body to { code, message, details, hint }
trace false emit a W3C traceparent header on every request

headers as a function is the hook for runtime tokens — a bearer token or any short-lived credential — re-evaluated on every request.

trace: true mints a fresh root trace per request. The stitchkit server continues an inbound traceparent, so the browser call, the HTTP handler and every nested tool call share one trace id end-to-end. A traceparent you set yourself (via headers) always wins.

Retry is deliberately conservative: only a connection that never landed (a network error), only on idempotent GET. A server that responded with a 5xx is the data layer’s call (TanStack Query), not the transport’s — retrying in both places multiplies attempts.

createClient

import { createClient } from 'stitchkit'
import { users } from '../shared/contracts'

export const api = createClient(users, http)

await api.list()                   // GET    /users
await api.create({ name: 'Max' })  // POST   /users      body: { name }
await api.get({ id: '1' })         // GET    /users/1
await api.update({ id: '1', name: 'M' })  // PUT /users/1  body: { name }
await api.delete({ id: '1' })      // DELETE /users/1

Each call takes one argument object. The client routes each field by the contract:

  • a path param (:id) is substituted into the URL,
  • for GET / DELETE, the remaining fields become the query string (arrays become repeated keys),
  • for POST / PUT / PATCH, they become the JSON body,
  • a multipart field is a Blob (web / Bun) or a platform FileDescriptor ({ uri, name, type }, for React Native / Expo) and is sent as form-data. The exported MultipartFile / FileDescriptor types let you annotate your own upload helpers.

Many contracts at once

import { createClients } from 'stitchkit'

export const api = createClients({ users, posts, billing }, http)
await api.users.list()
await api.posts.create({ title: 'Hi' })

createClients builds one typed client per contract from a registry — list the contracts once, get the whole API typed.

ApiError

A non-2xx response is thrown as an ApiError:

import { ApiError } from 'stitchkit'

try {
  await api.get({ id: 'missing' })
} catch (err) {
  if (ApiError.is(err)) {
    err.code     // 'NOT_FOUND'
    err.status   // 404
    err.message  // 'Note not found'
    err.details  // structured details, if any
    err.hint     // optional hint
  }
}

The error model is shared with the server — see Auth & errors.

Auth events

The HTTP client emits events your app can react to globally:

const unsubscribe = http.subscribe((event) => {
  if (event.type === 'unauthorized') redirectToLogin()  // a 401 outside authEndpoints
  if (event.type === 'network_error') showOfflineBanner()
})

http.logout()            // mark logged out — suppresses further unauthorized events
http.resetLogoutState()  // clear it after a fresh login

Server-side rendering

For SSR, forward the incoming request’s cookies so the API call runs as the logged-in user:

http.setServerContext(request.headers.get('cookie') ?? '')

A bare fetch client

If you do not need cookie auth, retry or the event stream, pass a plain config instead of an HttpClientcreateClient then builds a minimal fetch-based client:

const api = createClient(users, {
  baseUrl: 'https://api.example.com',
  headers: () => ({ Authorization: `Bearer ${token()}` }),
  onError: (status, body) => console.warn(status, body),
})

ContractClientConfig — per-tenant / resource-scoped clients

createClient takes an optional third argument that prepends a dynamic segment to every URL — the client half of a multi-tenant API (Route groups → param prefixes):

interface ContractClientConfig {
  /** Prepended to every request URL. A function is called per request with the
   *  call's argument object, so the prefix can depend on the arguments. */
  pathPrefix?: string | ((args: Record<string, unknown>) => string)
  /** Argument keys consumed by `pathPrefix` — stripped from the query/body so
   *  they are not also sent there (the endpoint's own path `:params` are
   *  stripped automatically; list any *extra* keys here). */
  stripPrefixKeys?: string[]
}

A per-tenant client — tenantId goes into the URL, not the body:

const widgets = createClient(widgetsContract, http, {
  pathPrefix: (args) => `tenants/${args.tenantId}/`,
  stripPrefixKeys: ['tenantId'],
})

widgets.list({ tenantId: 't_123' })            // GET  /tenants/t_123/widgets
widgets.create({ tenantId: 't_123', name: 'A' }) // POST /tenants/t_123/widgets  body: { name }

“Keys it consumes” = a key the pathPrefix function reads (here tenantId). List it in stripPrefixKeys so it lands in the URL and is removed from the query/body — otherwise it would be sent twice. Endpoint path :params are stripped for you; only extra prefix keys need listing.

React data layer

stitchkit does not ship a hook engine. Pair the typed client with react-query-kit — wrap the client methods directly:

import { createQuery, createMutation } from 'react-query-kit'
import { api } from './api'

export const useUsers      = createQuery({ queryKey: ['users'], fetcher: () => api.list() })
export const useCreateUser = createMutation({ mutationFn: api.create })

Cursor pagination

For a cursor-paginated list, createCursorQuery is the canonical helper:

import { createCursorQuery } from 'stitchkit/react'
import { api } from './api'

export const useFeed = createCursorQuery({
  queryKey: ['feed'],
  endpoint: api.feed,     // the contract method
})

It injects cursor from the page param and bakes in getNextPageParam / initialPageParam — an infinite hook is just queryKey + endpoint. The page size is the server’s call (the contract’s limit default); the client never sends one. The result keeps the full react-query-kit surface (.getKey(), useSuspenseInfiniteQuery, every option). The endpoint must return the { items, nextCursor } envelope — see Contracts → pagination.

SSE

For a streaming endpoint, consume the response with parseSSE:

import { parseSSE } from 'stitchkit'

const res = await fetch('/api/chat/stream', { method: 'POST', body })
for await (const event of parseSSE(res)) {
  console.log(event.data)
}

The server side is streamSSE.