A typed, modular, universal API client factory for TypeScript — with a full request lifecycle (queue → dedup → cache → auth → retry → validate), OpenAPI codegen, multi-tenancy, and first-class TanStack Query support for React, Vue, and Solid.
This is the complete guide. It starts from zero and walks through every feature. If you have never used the library before, read top to bottom once — after that you can jump straight to the reference tables.
📖 Prefer bite-sized, per-feature pages? The
docs/folder splits this guide into one focused page per feature, each cross-linked to the exact example source that demonstrates it. Start at the docs index.🧪 Want to see it run? Two apps back every feature with no backend setup:
examples/react-vite(client-side: direct client, TanStack Query, an interactive Feature Lab) andexamples/nextjs(the SSR RPC bridge).
- What is this library?
- Mental model (read this first)
- Installation
- Your first client in 5 minutes
- Defining modules and methods
- Making calls & the response envelope
- Configuration reference
- Authentication
- Caching
- Deduplication
- Retries & backoff
- Timeouts & cancellation
- Concurrency queue
- Multi-tenancy
- Multiple environments & base URLs
- Error handling
- Hooks & events
- Code generation (CLI)
- Runtime schema validation & drift detection
- TanStack Query — React
- TanStack Query — Vue
- TanStack Query — Solid
- SSR without leaking your backend (RPC bridge)
- Framework & runtime guides
- Testing your code
- Full public API reference
- Troubleshooting & FAQ
- Roadmap (planned features)
@developerehsan/api-client turns an HTTP/REST backend into a typed, ergonomic
client object you call like this:
const { data } = await api.invoices.get('inv_123')
// ^ fully typed ^ your module ^ your methodInstead of scattering fetch/axios calls across your app, you configure one
client once. Every request then flows through a consistent pipeline that
handles authentication, caching, deduplication, retries, timeouts,
cancellation, multi-tenancy, and (optionally) response validation — so your UI
code stays clean.
| Area | What you get |
|---|---|
| Typed proxy | api.[module].[method](args) with full input/output inference |
| Adapters | Axios (default) or native fetch; auto-fallback to fetch on edge |
| Auth | Bearer, Cookie, API key, OAuth2 (with automatic 401→refresh→retry) |
| Caching | In-memory LRU, TTL, cache-first / network-first / stale-while-revalidate, glob invalidation |
| Dedup | Identical in-flight requests share one network call |
| Retries | Exponential/linear/fixed backoff, full-jitter, honors Retry-After |
| Timeouts | Per-request, enforced on every adapter (incl. fetch) |
| Cancellation | AbortSignal support + debounce-cancel |
| Concurrency | Global request queue with a configurable limit |
| Multi-tenancy | Per-call / per-module / global tenant resolution + server context |
| Environments | Named base URLs, switch at runtime |
| Codegen | Generate TS types + module descriptors from an OpenAPI 3.x spec |
| Validation | Runtime response validation + schema drift detection |
| TanStack Query | Typed queryOptions / mutationOptions / infiniteQueryOptions for React, Vue, Solid |
| SSR RPC bridge | Call api.module.method() from client components in Next.js / TanStack Start without exposing the backend URL, paths, or OpenAPI to the browser |
| RPC batching | Coalesce same-tick bridge calls into one round-trip; each sub-call validated + authorized individually |
| RPC rate limiter | Built-in createRateLimiter for the handler onRequest (per-IP / per-session, pluggable store) |
| Streaming | ctx.stream() → AsyncIterable for NDJSON / SSE / raw byte streams (client-side) |
| Modules beyond HTTP | ctx.run() runs any async logic with opt-in queue/dedup/retry/timeout; ctx.emit / ctx.logger / ctx.config |
| Cache persistence | Pluggable L2 stores (IndexedDB / Redis) behind the in-memory LRU |
| Auto codegen | Config-file-driven generate / watch / --check; Vite plugin + Next.js integration |
| Hooks | onRequest/onResponse/onError/onRetry/onSuccess/onSettled composed across global → module → per-call |
| Testing | createMockClient + MockAdapter |
| Package | Import | Purpose |
|---|---|---|
@developerehsan/api-client |
@developerehsan/api-client |
The runtime library |
| — codegen entry | @developerehsan/api-client/codegen |
Node-only codegen functions (used by the CLI) |
| — testing entry | @developerehsan/api-client/testing |
Mock client & adapter |
| — server entry | @developerehsan/api-client/server |
SSR RPC bridge — server half (createRpcHandler, Next/route glue) |
| — browser entry | @developerehsan/api-client/browser |
SSR RPC bridge — dependency-free browser client (createRpcClient, transports) |
@developerehsan/api-client-cli |
npx @developerehsan/api-client |
Codegen CLI |
@developerehsan/api-client-query |
.../query/react | /vue | /solid |
TanStack Query integration |
There are exactly three concepts to understand.
1. The client — created once with createClient(config). It holds all your
global settings and the shared state (cache, dedup map, queue).
2. Modules & methods — you group endpoints into modules (e.g. invoices,
users) and declare methods on them with defineModule. Each method uses the
ctx.request(...) primitive to actually perform an HTTP call. Method names and
paths are yours to choose.
3. The pipeline — every ctx.request(...) runs through this ordered
lifecycle:
your call
│
▼
resolve config (global → module → per-call, deep-merged)
│
▼
concurrency queue ──▶ deduplication ──▶ cache lookup
│ │ (hit? return)
▼ ▼ (miss)
resolve tenant + auth headers
│
▼
dispatch (with timeout) ──▶ retry on 5xx/network/timeout
│ │
│ └─ 401? → refresh token → retry once
▼
validate response (optional) ──▶ write cache ──▶ return ApiResponse<T>
Codegen vs. runtime. The CLI generates types and a module descriptor map from your OpenAPI spec. Those give you compile-time safety and feed the TanStack integration. The actual runtime methods you call are the ones you declare with
defineModule— usually thin wrappers overctx.request(...)that reference the generated paths. This keeps runtime behavior explicit and debuggable.
# pick your package manager
pnpm add @developerehsan/api-client
npm install @developerehsan/api-client
yarn add @developerehsan/api-clientInstall only what you use — none are bundled, so unused ones add zero bytes:
# Axios adapter (default adapter). Skip it to run purely on fetch.
pnpm add axios
# Runtime response validation helpers you might call directly.
pnpm add zod # optional; the built-in validator needs no zod
# TanStack Query integration (choose your framework)
pnpm add @developerehsan/api-client-query @tanstack/react-query
pnpm add @developerehsan/api-client-query @tanstack/vue-query
pnpm add @developerehsan/api-client-query @tanstack/solid-query- TypeScript 5+ (strict mode recommended)
- Node 18+ / any modern browser / edge runtimes (Vercel Edge, Cloudflare Workers)
Create one file that configures and exports the client. Import this everywhere.
// src/api.ts
import { createClient, defineModule } from '@developerehsan/api-client'
export const api = createClient({
// Where your API lives.
baseURL: 'https://api.example.com',
// The library can work with or without an OpenAPI spec. In 'runtime' mode it
// can fetch the spec for validation; in 'codegen' mode you generate types
// ahead of time. Start simple:
openapi: { mode: 'runtime' },
// How to authenticate (see §8 for all strategies).
auth: {
strategy: 'bearer',
getToken: () => localStorage.getItem('access_token'),
},
// Sensible defaults for the whole app.
http: { timeout: 10_000, retry: { attempts: 3 } },
cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },
// Declare your modules (see §5).
modules: {
users: defineModule({
methods: {
list: async (ctx, params?: { page?: number }) =>
(await ctx.request({ method: 'GET', path: '/users', query: params })).data,
get: async (ctx, id: string) =>
(await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,
create: async (ctx, body: { name: string; email: string }) =>
(await ctx.request({ method: 'POST', path: '/users', body })).data,
},
}),
},
})Now use it anywhere:
import { api } from './src/api'
const users = await api.users.list({ page: 1 })
const user = await api.users.get('user_42')
const made = await api.users.create({ name: 'Ada', email: 'ada@x.com' })That is a fully working client with auth, caching, retries, timeouts, and dedup already active.
A module is a named group of methods. You declare it with defineModule,
whose methods each receive a ctx (module context) as their first
argument. Callers never pass ctx — it is injected for you:
defineModule({
methods: {
// You write: (ctx, ...yourArgs)
// Callers use: api.invoices.get(id, perCall?)
get: async (ctx, id: string, perCall?) =>
(await ctx.request(
{ method: 'GET', path: '/invoices/{id}', pathParams: { id } },
perCall,
)).data,
},
})The one primitive that runs the pipeline.
interface ModuleRequestSpec {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
path: string // supports {placeholders}
pathParams?: Record<string, string | number>
query?: Record<string, unknown> // serialized to the query string
body?: unknown // JSON-serialized for you
}
// Returns Promise<ApiResponse<T>> — see §6.
ctx.request<T>(spec, perCall?): Promise<ApiResponse<T>>- Path params:
path: '/orders/{orderId}/lines/{lineId}'+pathParams: { orderId, lineId }. A missing required placeholder throws aConfigurationErrorbefore any network call. - Query:
query: { page: 1, tags: ['a', 'b'] }→?page=1&tags=a&tags=b.undefined/nullvalues are skipped. - Per-call overrides: pass a second argument to override config for just this call (see §7.3).
Annotate the generic so callers get typed results:
type Invoice = { id: string; amount: number; status: 'draft' | 'paid' }
list: async (ctx): Promise<Invoice[]> =>
(await ctx.request<Invoice[]>({ method: 'GET', path: '/invoices' })).data,If you use the codegen (see §18), import the generated types instead of writing them by hand.
A method can perform several requests and combine them. Access other modules via
ctx.client:
getWithLines: async (ctx, id: string) => {
const invoice = (await ctx.request({ method: 'GET', path: '/invoices/{id}', pathParams: { id } })).data
const lines = (await ctx.request({ method: 'GET', path: '/invoices/{id}/lines', pathParams: { id } })).data
return { invoice, lines }
},defineModule({
config: {
baseURL: 'https://payroll.internal', // this module hits a different host
timeout: 30_000,
auth: { strategy: 'apiKey', getKey: () => process.env.PAYROLL_KEY!, placement: 'header', name: 'X-Key' },
},
methods: { /* ... */ },
})Your module methods return whatever you return from them (above we returned
.data). Internally ctx.request resolves to an ApiResponse<T>:
interface ApiResponse<T> {
data: T // parsed body (null for 204/empty)
status: number // HTTP status
statusText?: string
headers: Record<string, string> // response headers
fromCache?: boolean // true when served from cache
}Return .data for a clean API, or return the whole envelope if callers need
status/headers:
getRaw: async (ctx, id: string) =>
ctx.request<Invoice>({ method: 'GET', path: '/invoices/{id}', pathParams: { id } }),
// caller: const { data, status, fromCache } = await api.invoices.getRaw('1')Failures throw typed errors by default (see §16), or return a discriminated
result if you enable safeMode.
Configuration comes in three layers and is deep-merged in this order (later wins):
library defaults → global config → module config → per-call config
Arrays (e.g. header sets) are merged, not replaced.
interface GlobalConfig {
baseURL: string
environments?: Record<string, string> // named base URLs (see §15)
activeEnvironment?: string
openapi: {
mode?: 'codegen' | 'runtime' | 'auto' // default 'auto'
schemaPath?: string // for codegen
runtimeURL?: string // fetch spec at runtime (dev)
validation?: {
enabled?: boolean
mode?: 'strict' | 'loose' // throw vs. warn
onDriftDetected?: (diff) => void
}
}
http?: {
adapter?: 'axios' | 'fetch' | HttpAdapter // default 'axios'
timeout?: number // ms, default 10_000
headers?: Record<string, string>
deduplication?: boolean // default true
dedupeMethod?: string[] // default ['GET']
queue?: { enabled?: boolean; concurrency?: number; priority?: 'fifo' | 'lifo' }
retry?: {
attempts?: number // default 3
backoff?: 'exponential' | 'linear' | 'fixed'
baseDelay?: number; maxDelay?: number // ms
jitter?: boolean // default true (full-jitter)
retryOn?: (error: ApiError) => boolean
onRetry?: (attempt: number, error: ApiError) => void
}
}
auth?: AuthConfig // see §8
cache?: CacheConfig // see §9
cancellation?: { dedupeWindow?: number; cancelOnUnmount?: boolean }
tenancy?: { headerName?: string; getTenantId?: () => string | Promise<string> }
dev?: { logging?: boolean | 'verbose'; validateResponses?: boolean; schemaRefreshInterval?: number }
hooks?: LifecycleHooks // see §17
safeMode?: boolean // see §16.4
modules?: Record<string, ModuleDefinition>
}Set on a module via defineModule({ config: { ... } }). Supported keys:
baseURL, timeout, headers, auth, cache, retry, tenancy,
validation. Each overrides the global value for that module only.
Pass as the last argument through your method to ctx.request(spec, perCall):
interface PerCallConfig {
signal?: AbortSignal // cancellation
headers?: Record<string, string>
tenantId?: string // override tenant for this call
cache?: { enabled?: boolean; ttl?: number; bust?: boolean }
retry?: { attempts?: number }
timeout?: number
skipAuth?: boolean // send unauthenticated
skipDedup?: boolean
responseType?: 'json' | 'blob' | 'text' | 'arraybuffer'
}Example:
await api.users.get('42', { timeout: 2000, cache: { bust: true } })Set auth globally, per module, or per call. Four strategies plus "none".
auth: {
strategy: 'bearer',
getToken: () => localStorage.getItem('access_token'), // sync or async
headerName: 'Authorization', // default
prefix: 'Bearer', // default
onMissingToken: 'warn', // 'throw' | 'skip' | 'warn' (default 'warn')
}getTokenmay be async (e.g. read from secure storage).- If it returns
null:warnsends the request unauthenticated,skipsends without the header silently,throwraises anAuthError. - If it throws, the request is not sent and an
AuthErroris raised.
auth: { strategy: 'cookie' } // sends credentials: 'include' automaticallyMake sure your server sends Access-Control-Allow-Credentials: true.
auth: {
strategy: 'apiKey',
getKey: () => process.env.API_KEY!,
placement: 'header', // or 'query'
name: 'X-API-Key', // header name or query-param name
}Handles the full 401 → refresh → retry-once flow, and coalesces concurrent 401s so only one refresh runs at a time (per config):
auth: {
strategy: 'oauth2',
getAccessToken: () => tokenStore.access,
getRefreshToken: () => tokenStore.refresh,
refreshEndpoint: 'https://api.example.com/oauth/token',
// Optional: shape the refresh request body.
refreshPayload: (refreshToken) => ({ grant_type: 'refresh_token', refresh_token: refreshToken }),
// Called on success — persist the new tokens here.
onTokensRefreshed: (tokens) => { tokenStore.access = tokens.accessToken; if (tokens.refreshToken) tokenStore.refresh = tokens.refreshToken },
// Called when refresh fails — usually redirect to login.
onRefreshFailed: (error) => { redirectToLogin() },
concurrentRefreshStrategy: 'queue', // 'queue' (default) or 'race'
}The refresh response is expected to contain access_token/accessToken (and
optionally refresh_token/refreshToken). A second 401 after refreshing is
not re-refreshed (prevents infinite loops).
await api.public.getStatus(undefined, { skipAuth: true })Never read localStorage on the server. Use the provided helper:
import { serverTokenFromCookie } from '@developerehsan/api-client'
auth: { strategy: 'bearer', getToken: serverTokenFromCookie('access_token') }Caching applies to GET requests. Configure globally, per module, or per call.
cache: {
enabled: true, // default true
strategy: 'stale-while-revalidate', // see below
ttl: 60_000, // ms until an entry is stale
maxSize: 500, // LRU capacity
onEvict: (key, entry) => {}, // optional
}| Strategy | Behavior |
|---|---|
cache-first (default) |
Return a fresh entry if present; otherwise fetch and cache. |
network-first |
Try the network; on failure fall back to a cached entry if one exists. |
stale-while-revalidate |
Return a stale entry immediately, revalidate in the background, keep the stale copy if revalidation fails. |
Keys include the HTTP method, URL, tenant id, and an auth fingerprint — so two users with different tokens never share a cached response.
api.cache.get(key) // read a raw entry
api.cache.clear() // wipe everything
api.cache.invalidate('users.*') // glob invalidation (* wildcard)
// Per call: skip the cache and refresh it.
await api.users.list(params, { cache: { bust: true } })createClient({
hooks: {
onCacheHit: (key, entry) => {},
onCacheMiss: (key) => {},
},
})
// or: api.on('cacheHit', ({ key, entry }) => {})Identical in-flight requests are collapsed into one network call; every
caller receives the same result (or the same error). On by default for GET.
http: {
deduplication: true, // default
dedupeMethod: ['GET'], // add 'POST' etc. to dedupe those too
}
// Opt out for a single call:
await api.users.get('42', undefined, { skipDedup: true })Dedup keys include the auth fingerprint and tenant, so requests with different credentials are never merged.
Failed requests are retried when they are retryable (5xx, 429, network, and timeout errors by default).
http: {
retry: {
attempts: 3, // total tries
backoff: 'exponential', // 'exponential' | 'linear' | 'fixed'
baseDelay: 500, // ms
maxDelay: 30_000, // ms — hard ceiling (also caps Retry-After)
jitter: true, // full-jitter to avoid thundering herds
retryOn: (error) => error.status === 503, // custom predicate
onRetry: (attempt, error) => console.warn('retry', attempt, error.status),
},
}- A
429/503with aRetry-Afterheader is honored (seconds or HTTP-date), but never longer thanmaxDelay. - Backoff waits are abort-interruptible — cancelling stops the wait immediately.
- 4xx (except 401 handled by auth) are not retried by default.
Set http.timeout (or per call). Enforced on every adapter — including
fetch, which does not time out on its own. Exceeding it raises a
TimeoutError (which is retryable). Each retry attempt gets a fresh budget.
await api.reports.generate(input, { timeout: 60_000 })const controller = new AbortController()
const promise = api.users.list(params, { signal: controller.signal })
controller.abort() // rejects with an AbortError; no further retriesAbortErrors propagate to the caller as-is (they are never swallowed or turned
into cache hits), even under safeMode.
Set cancellation.dedupeWindow so a newer call for the same endpoint within the
window auto-cancels the previous in-flight one:
createClient({ cancellation: { dedupeWindow: 300 } })
// Rapid api.search.query('a'), ('ab'), ('abc') — earlier ones are aborted.Limit how many requests are in flight at once (useful against rate limits):
http: {
queue: {
enabled: true, // default
concurrency: 6, // max simultaneous requests (default 10)
priority: 'fifo', // 'fifo' (default) or 'lifo'
},
}Requests beyond the limit wait their turn. Aborting a queued (not-yet-started) request removes it from the queue and rejects it.
Inject a tenant id header, resolved with this precedence:
per-call tenantId → configured getTenantId() → ambient server context
createClient({
tenancy: {
headerName: 'X-Tenant-ID', // default
getTenantId: () => currentTenant.id, // sync or async
},
})
// Per-call override:
await api.invoices.list(params, { tenantId: 'acme' })If nothing resolves, no tenant header is sent (tenant-agnostic endpoints are
fine). If getTenantId throws, a ConfigurationError is raised before the call.
Cache/dedup keys include the tenant id, so tenants never see each other's data.
AsyncLocalStorage keeps concurrent server requests isolated:
import { runWithTenant, getTenantFromContext, serverTenantResolver } from '@developerehsan/api-client'
// Configure the resolver to read the ambient context (or a request header):
createClient({ tenancy: { getTenantId: getTenantFromContext } })
// or read a request header directly:
createClient({ tenancy: { getTenantId: serverTenantResolver('x-tenant-id') } })
// Wrap per-request server work so each request has its own tenant:
export async function handler(tenantId: string) {
return runWithTenant(tenantId, async () => {
return api.invoices.list() // sees `tenantId`, isolated from other requests
})
}createClient({
environments: {
dev: 'http://localhost:3000',
staging: 'https://staging.example.com',
prod: 'https://api.example.com',
},
activeEnvironment: 'dev', // picks the base URL; must exist in the map
openapi: { mode: 'runtime' },
})
// Switch at runtime — this also clears the cache:
api.setEnvironment('staging')- An unknown
activeEnvironmentthrows aConfigurationErroratcreateClienttime (fail fast). - A module can target a different host with
config.baseURL(see §5).
Every failure is one of these (all extend ApiError):
| Class | When |
|---|---|
ApiError |
Base; also used for generic 4xx/5xx. Has status, code, serverError, rawBody, retryCount, responseHeaders. |
NetworkError |
No response (offline, DNS, CORS). Flags: corsBlocked, offline, partial. |
TimeoutError |
Exceeded the configured timeout. Has timeoutMs. |
AuthError |
401 with no/failed refresh, or token getter failure. |
SchemaError |
Response validation failed (strict) or drift detected. |
SchemaParseError |
Malformed/unsupported OpenAPI spec (codegen/parse). |
ConfigurationError |
Bad config, missing path param, failing tenant resolver. |
import { ApiError, AuthError, TimeoutError } from '@developerehsan/api-client'
try {
await api.users.get('42')
} catch (err) {
if (err instanceof AuthError) redirectToLogin()
else if (err instanceof TimeoutError) toast('Timed out, retry?')
else if (err instanceof ApiError) console.error(err.status, err.serverError)
}{ code, message, details }bodies are parsed intoerror.serverError.- Non-JSON bodies (e.g. an HTML 5xx page) are kept in
error.rawBody; theerror.messageis a short, truncated hint (never the whole document).
createClient({ hooks: { onError: (error) => reportToSentry(error) } })Return a discriminated union instead of throwing:
createClient({ safeMode: true })
const result = await api.users.get('42')
if (result.success) use(result.data)
else handle(result.error) // result.error is an ApiError(AbortErrors still throw even in safeMode, so cancellation stays observable.)
createClient({
hooks: {
onRequest: (req) => ({ ...req, headers: { ...req.headers, 'X-Trace': id() } }),
onResponse: (res) => res,
onError: (err) => log(err),
onRetry: (attempt, err) => {},
onCacheHit: (key, entry) => {},
onCacheMiss:(key) => {},
},
})onRequest/onResponse may transform (and must return) their argument.
const handler = (payload) => {}
api.on('request', handler) // 'request' | 'response' | 'error' | 'cacheHit' | 'cacheMiss'
api.off('request', handler)createClient({ dev: { logging: 'verbose' } }) // true | 'verbose' | falseGenerate TypeScript types and a module descriptor map from an OpenAPI 3.x spec.
# Generate types + descriptors
npx @developerehsan/api-client generate \
--input ./openapi.json \
--output ./src/generated \
--base-url https://api.example.com
# Re-generate on change
npx @developerehsan/api-client generate --watch
# Validate a spec (CI-friendly; no file writes)
npx @developerehsan/api-client validate --input ./openapi.json
# Show what changed since the last generation
npx @developerehsan/api-client diff --input ./openapi.json --output ./src/generatedsrc/generated/
├── api.types.ts # interfaces + an OperationsMap (DO NOT EDIT)
├── api.modules.ts # `generatedModules` descriptor map (DO NOT EDIT)
├── api.schema.hash # spec hash for drift detection
└── overrides/ # put your custom type augmentations here (safe to edit)
Use the generated types for your method signatures, and the generated
generatedModules descriptor with the TanStack integration (§20–22). The
parser handles $ref, allOf/oneOf/anyOf, nullable, enums, and circular
references.
In runtime mode the client can fetch your spec and validate responses against
it — great for catching backend drift in development.
createClient({
openapi: {
mode: 'runtime',
runtimeURL: 'https://api.example.com/openapi.json',
validation: {
enabled: true,
mode: 'loose', // 'loose' warns; 'strict' throws a SchemaError
onDriftDetected: (diff) => console.warn('schema drift', diff),
},
},
dev: { schemaRefreshInterval: 30_000 }, // re-fetch + diff periodically
})
api.getSchema() // the loaded SchemaAST (or undefined before it loads)- Validation is dependency-free (no
zodrequired); it checks bodies structurally, including enums, unions,$refs, andadditionalProperties. - If a refresh fails, the last known-good schema is kept.
- Drift detection compares operation signatures and body/response types.
Install @developerehsan/api-client-query and @tanstack/react-query, then build
an integration from your client + a descriptor map (the generated
generatedModules, or a hand-written one).
// query.ts
import { createQueryIntegration } from '@developerehsan/api-client-query/react'
import { generatedModules } from './generated/api.modules' // or a manual map
import { api } from './api'
export const q = createQueryIntegration(api, { modules: generatedModules })// UsersPage.tsx
import { useQuery, useMutation, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { q } from './query'
function UsersPage() {
const queryClient = useQueryClient()
// Query
const { data, isLoading } = useQuery(q.users.queryOptions.list({ page: 1 }))
// Mutation + cache invalidation
const create = useMutation(
q.users.mutationOptions.create({
onSuccess: () => q.users.invalidateQueries(queryClient),
}),
)
// Infinite query (auto-detected for paginated endpoints)
const infinite = useInfiniteQuery(q.users.infiniteQueryOptions.list({ limit: 20 }))
return null
}- Query keys are stable & hierarchical:
['developerEhsan', module, method, params]. - Passing
null/undefinedparams to a query that needs them setsenabled: falseautomatically (dependent queries). - The
AbortSignalfrom TanStack is forwarded into the pipeline, so unmounting cancels the request.
Manual descriptor map (if you are not using codegen):
const modules = {
users: {
list: { method: 'GET', path: '/users', isPaginated: true },
get: { method: 'GET', path: '/users/{id}' },
create: { method: 'POST', path: '/users' },
},
}
export const q = createQueryIntegration(api, { modules })Same integration, imported from the /vue entry. The option objects plug
directly into @tanstack/vue-query.
// query.ts
import { createQueryIntegration } from '@developerehsan/api-client-query/vue'
import { generatedModules } from './generated/api.modules'
import { api } from './api'
export const q = createQueryIntegration(api, { modules: generatedModules })<script setup lang="ts">
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { q } from './query'
const queryClient = useQueryClient()
const { data, isLoading } = useQuery(q.users.queryOptions.list({ page: 1 }))
const create = useMutation(
q.users.mutationOptions.create({
onSuccess: () => q.users.invalidateQueries(queryClient),
}),
)
</script>Note: with Vue Query you typically pass reactive params. Call the factory with the current values inside a computed if you need reactivity, e.g.
computed(() => q.users.queryOptions.list({ page: page.value })).
Import from the /solid entry; use with @tanstack/solid-query.
// query.ts
import { createQueryIntegration } from '@developerehsan/api-client-query/solid'
import { generatedModules } from './generated/api.modules'
import { api } from './api'
export const q = createQueryIntegration(api, { modules: generatedModules })import { useQuery, useMutation } from '@tanstack/solid-query'
import { q } from './query'
function Users() {
const query = useQuery(() => q.users.queryOptions.list({ page: 1 }))
const create = useMutation(() => q.users.mutationOptions.create())
return null
}Solid Query expects a function returning the options object, so wrap the factory call in an arrow:
useQuery(() => q.users.queryOptions.list(params)).
In a client-side app the client runs in the browser and calls your backend directly — the base URL and paths are visible in the Network tab by design. In an SSR framework (Next.js, TanStack Start) you often don't want the browser to see:
- the backend base URL,
- the backend paths (
/pet/{petId},/internal/billing/...), - the OpenAPI document.
If you import the normal client into a client component, all of that ships in
the JS bundle and every call appears in the Network tab pointing at your real
backend. The bridge solves this without giving up the api.module.method()
ergonomics.
Browser (client component) Server (Node / edge)
─────────────────────────── ─────────────────────────────
api.products.getProductById({ id }) createRpcHandler(realApi, { expose })
│ proxy, typed via `typeof serverApi` │ allowlist → authorize → dispatch
│ (type-only, erased at build) ▼ runs the REAL client (holds secrets)
▼ POST same-origin { module,method,args }
transport ───────────────────────────────▶ { ok: true, data } | { ok: false, error }
- The browser proxy carries zero runtime knowledge of URL/paths/OpenAPI —
types come purely from
type Api = typeof serverApi, a type-only import that is erased at build. (The compiled browser bundle is a few KB and contains no backend host, paths, or axios.) - The server handler is the single trust boundary: everything the browser sends is treated as hostile until validated.
// lib/api/api.config.ts (server-only module)
import { createTypedClient } from '@developerehsan/api-client'
import { createRpcHandler } from '@developerehsan/api-client/server'
import type { OperationsMap } from './types/generated/api.types'
import { generatedModules } from './types/generated/api.modules'
export const api = createTypedClient<OperationsMap>()(
{ baseURL: process.env.API_URL!, openapi: { mode: 'runtime' } },
generatedModules,
)
export type Api = typeof api
export const rpcHandler = createRpcHandler(api, {
// Deny-by-default allowlist. Module AND method names autocomplete here, and a
// typo is a compile error. `store: true` would expose every store method.
expose: {
pet: ['getPetById', 'findPetsByStatus', 'addPet'],
store: ['getInventory'],
},
// Per-call permission (reads open, writes gated by a session cookie).
authorize: async (ctx, call) => {
if (call.method === 'addPet') return (await ctx.getCookie?.('editor')) === '1'
return true
},
onRequest: (_ctx, call) => console.log(`[rpc] ${call.module}.${call.method}`),
transformResult: (result, call) => result, // redact fields here if needed
onError: (error, call) => console.error(`[rpc] ${call?.method} failed`, error),
})Option A — Next.js Server Action (CSRF handled by Next):
// app/actions.ts
'use server'
import { createNextRpcAction } from '@developerehsan/api-client/server'
import { rpcHandler } from '@/lib/api/api.config'
export const rpc = createNextRpcAction(rpcHandler)Option B — generic HTTP route (framework-agnostic; enforces its own CSRF):
// app/api/rpc/route.ts
import { createRpcRouteHandler } from '@developerehsan/api-client/server'
import { rpcHandler } from '@/lib/api/api.config'
export const POST = createRpcRouteHandler(rpcHandler)
// { allowedOrigins?, maxBodyBytes = 128*1024, csrf = true }// lib/api/rpc-client.ts
import { createRpcClient, serverActionTransport } from '@developerehsan/api-client/browser'
import type { Api } from './api.config' // ← type-only import; erased at build
import { rpc } from '@/app/actions'
export const api = createRpcClient<Api>(serverActionTransport(rpc))
// Or, for the generic route:
// import { httpTransport } from '@developerehsan/api-client/browser'
// export const api = createRpcClient<Api>(httpTransport({ endpoint: '/api/rpc' }))'use client'
import { api } from '@/lib/api/rpc-client'
import { ApiError } from '@developerehsan/api-client/browser'
async function load() {
try {
const product = await api.products.getProductById({ id: 1 }) // → Product, typed
} catch (e) {
if (e instanceof ApiError) console.log(e.status, e.message) // rehydrated!
}
}TanStack Query works over the bridge too — point createQueryIntegration at the
bridge client and the paths-stripped descriptor the codegen emits
(api.rpc.ts, which contains verbs + hasPathParams but no paths):
import { createQueryIntegration } from '@developerehsan/api-client-query/react'
import { api } from './rpc-client'
import { rpcModules } from './types/generated/api.rpc' // no backend paths
export const q = createQueryIntegration(api, { modules: rpcModules })Pass an AbortSignal as usual — api.products.getProductById({ id }, { signal }). The
signal is not sent over the wire (it isn't serializable); it's honored
locally and rejects the promise with an AbortError on abort.
The handler enforces all of the following before dispatch:
| Concern | Guarantee |
|---|---|
| Arbitrary method calls | Deny-by-default expose allowlist; client strings are validated, never used to index blindly |
| Prototype pollution | __proto__/constructor/prototype rejected in names and input |
| Authorization | Optional authorize(ctx, call); deny returns the same error as "unknown method" (no enumeration) |
| SSRF / option injection | Client perCall is dropped except a clamped timeout; no baseURL/adapter/headers/auth override |
| DoS | Input depth/breadth caps (maxInputDepth/maxInputKeys) + body-size cap on the HTTP route |
| CSRF | Server Actions: Next's built-in protection. HTTP route: POST + application/json + Origin/Sec-Fetch-Site check |
| Error leakage | Only { name, status, code, message } cross the wire; stacks, request URLs, and headers never do (details only when dev: true) |
Note: the bridge client type mirrors your whole API surface, so
api.products.deleteProduct(...)still type-checks even if it isn't exposed — theexposeallowlist is the runtime gate, and an un-exposed call is denied.
A complete, runnable example lives in examples/nextjs.
Create the client once in src/api.ts and import it. Combine with the React
Query integration (§20). Nothing special required — the client runs entirely in
the browser and talks to your backend directly.
There are two ways to use the client in Next.js. Pick based on where your backend URL is allowed to be seen:
-
Server-only (RSC / Server Actions / route handlers) — call the real
apidirectly. The request happens on the server; nothing leaks. Use this for data you fetch during render.- Do not read
localStorageon the server. UseserverTokenFromCookie()/serverTenantResolver(). - Wrap per-request work in
runWithTenant()when multi-tenant.
// server-safe client export const api = createClient({ baseURL: process.env.API_URL!, openapi: { mode: 'runtime' }, auth: { strategy: 'bearer', getToken: serverTokenFromCookie('access_token') }, tenancy: { getTenantId: serverTenantResolver('x-tenant-id') }, })
- Do not read
-
Client components that must call the API — do not import the real client (that would ship your base URL, backend paths, and OpenAPI into the browser bundle). Use the SSR RPC bridge (§23): a client-side proxy with the same
api.module.method()surface that forwards each call to a Server Action or route handler. The browser only ever seesPOST /with{ module, method, args }.
For TanStack Query prefetch/hydration, the queryOptions factories work in both
Server Components (prefetch + dehydrate) and client components
(HydrationBoundary); route client-component queries through the bridge client.
Works out of the box with the Axios adapter. Provide a server-appropriate
getToken (env var, secrets manager, etc.).
Import your client normally — the library detects the edge runtime and uses the
fetch adapter automatically (Axios is never loaded). You can also force it:
createClient({ http: { adapter: 'fetch' }, /* ... */ })Use the built-in mock client — no real network, full pipeline.
import { createMockClient } from '@developerehsan/api-client/testing'
import { defineModule } from '@developerehsan/api-client'
const { api, mock } = createMockClient({
modules: {
users: defineModule({
methods: {
get: async (ctx, id: string) =>
(await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,
},
}),
},
})
// Register responses (by method + URL substring, or a function responder):
mock.on('GET', '/users/1', { data: { id: '1', name: 'Ada' } })
mock.on('GET', '/users/', (req) => ({ status: 404, data: { message: 'not found' } }))
// Act + assert:
const user = await api.users.get('1')
expect(user).toEqual({ id: '1', name: 'Ada' })
expect(mock.callsTo('GET', '/users/1')).toHaveLength(1)createMockClient defaults to instant retries (no delays) for fast tests. You
can also use createMockAdapter() directly with a real createClient.
Factory
createClient(config: GlobalConfig): ApiClient— dynamic/untyped clientcreateTypedClient<OperationsMap>()(config, generatedModules): TypedApiClient— fully-typed, curried;config.modulesis the source of truth and overrides generated methods per-methodcreateModuleDefiner<Ops, typeof generatedModules>()→defineModule('store', {...})with method-name + input autocompletebuildModulesFromDescriptors(descriptors)— build runtime modules from a generated descriptor mapdefineModule({ config?, methods, extends? }): ModuleDefinition
Client instance members
api.[module].[method](...args)— your declared methodsapi.cache.get(key)/.clear()/.invalidate(pattern?)api.config.get()/.update(partial)api.setEnvironment(name)api.getSchema()api.on(event, handler)/api.off(event, handler)
Errors
ApiError, NetworkError, TimeoutError, AuthError, SchemaError,
SchemaParseError, ConfigurationError, classifyError, extractServerError
Adapters
createFetchAdapter(), createAxiosAdapter(instance?), detectEnvironment(),
type HttpAdapter
Auth
createAuthManager(deps), and config types
BearerAuthConfig / CookieAuthConfig / ApiKeyAuthConfig / OAuth2AuthConfig
Tenancy & server context
runWithTenant(id, fn), getTenantFromContext(), hasTenantContext(),
resolveTenantId(input), readServerHeader(name), readServerCookie(name),
serverTenantResolver(headerName?), serverTokenFromCookie(cookieName?)
Runtime schema
createSchemaCache(), createSchemaLoader(deps), diffSchemas(a, b),
hashSchema(ast), hasDrift(diff), handleDrift(diff, policy),
validateValue(value, type, ast), validateResponseBody(ast, path, method, status, body)
Standalone utilities (for advanced use)
createDeduplicator(), computeDedupeKey(input), createCache(config),
computeCacheKey(input), isFresh(entry, now), createQueue(config),
createCancellationManager(config), isAbortError(err), linkSignals(...signals),
withRetry(fn, opts, deps?), computeBackoff(...), parseRetryAfter(headers)
createRpcHandler(api, options): RpcHandler— options:expose(required, deny-by-default, typed allowlist),authorize?,onRequest?,onError?,transformResult?,maxInputDepth?,maxInputKeys?,maxTimeout?,dev?createNextRpcAction(handler): NextRpcAction— wrap as a Next.js Server ActioncreateRpcRouteHandler(handler, { csrf?, allowedOrigins?, maxBodyBytes? }): (Request) => Promise<Response>RpcSecurityError, typesRpcHandlerOptions,ExposeMap,RpcRequestContext,RpcCall,RpcResponse,RpcErrorShape
createRpcClient<Api>(transport): RpcClient<Api>— dependency-free proxy;Apiis a type-only importserverActionTransport(action),httpTransport({ endpoint, fetch?, headers? })ApiError(forinstanceofafter rehydration),isRpcErrorShape,isRpcResponse, typesTransport,RpcPerCall,RpcClient
generate(options), validate(input), diff(input, output),
parseOpenApi(doc), emitTypes(ast, opts?), emitModules(ast, opts?),
emitRpcModules(ast, opts?) (paths-stripped descriptor for the bridge).
generate emits api.types.ts, api.modules.ts, api.rpc.ts, and api.schema.hash.
createMockClient(options), createMockAdapter()
createQueryIntegration(client, { modules, getNextPageParam?, pageParamName? }),
moduleKey(module), methodKey(module, method, params?)
api.myModule.myMethod is not a function
You called a method you did not declare in defineModule. The runtime methods
are the ones you declare — codegen produces types/descriptors, not runtime
methods. Add the method (usually a thin ctx.request(...) wrapper).
My fetch request never times out.
It does now — timeouts are enforced by the client regardless of adapter. Make
sure you set http.timeout (default 10s).
Two different users got the same cached/deduped response.
This cannot happen: cache and dedup keys include an auth fingerprint and tenant
id. If you see stale data, check your getToken/getTenantId actually return
per-user values.
OAuth2 keeps refreshing in a loop.
A second 401 after a refresh is not re-refreshed — it surfaces as AuthError.
If refresh keeps failing, verify refreshEndpoint and that
onTokensRefreshed actually persists the new token your getAccessToken reads.
Edge deploy fails trying to load axios.
It shouldn't — the edge build uses fetch and never statically imports Axios.
Force http.adapter: 'fetch' if a bundler misdetects the runtime.
Response validation throws in production.
Set openapi.validation.mode: 'loose' (warn instead of throw), or disable it
outside development.
Cancelling a request still resolves with data.
Aborts reject with an AbortError and are never converted to cache hits, even
with network-first or safeMode. Ensure you pass the signal via per-call
config.
pnpm install
pnpm -r build # build all packages
pnpm -r typecheck # strict tsc
pnpm -r test # vitestMonorepo layout: packages/core (runtime), packages/cli (codegen),
packages/tanstack-query (framework integrations).
extends: 'auto'auto-modules from the runtime schema — module methods are now derived at runtime from a fetched OpenAPI document (resolved lazily as the schema loads).- Batching over the RPC bridge —
createRpcClient(transport, { batch: true })coalesces same-tick calls into one round-trip; each sub-call is validated and authorized individually server-side (maxBatchSize, nested-batch rejection). - Client-side streaming / async-iterable responses —
ctx.stream(spec, { mode: 'ndjson' | 'sse' | 'raw' })returns anAsyncIterable, plusparseSse/parseNdjsonhelpers. (Streaming through the RPC bridge remains a follow-up.) - Built-in rate limiter —
createRateLimiter({ windowMs, max })wires into the handleronRequest(per-IP withtrustProxy, or a customkeyFor; pluggable store). - Pluggable cache persistence —
@developerehsan/api-client/cache-stores(memory / IndexedDB / Redis) layered behind the in-memory LRU viacache.persistentStore. - First-party TanStack Start & Remix adapters —
createStartRpcRoute/createRemixRpcActionover the framework-agnostic route handler. - Config-driven codegen + framework glue —
api-client.config.*, watch with remote-URL polling,generate --checkfor CI, pluswithApiClientCodegen(Next.js) and@developerehsan/api-client-vite(Vite / TanStack Start). - Codegen: React Query hooks emission —
emitReactQueryHooksemits typeduseXxxhooks alongside the option factories. - Modules beyond HTTP —
ctx.rungives any async module logic opt-in queue / dedup / retry / timeout;ctx.emit/ctx.logger/ctx.configround out the module context.
- Richer per-module method-name autocomplete in the plain-object
config.modulesform — usecreateModuleDefinertoday for full operation-aware autocomplete. - Streaming through the RPC bridge — route-only, with duration/idle/backpressure hardening (Server Actions can't stream).
- OpenAPI 3.1 & webhooks — expand the parser beyond 3.0.x.
- GraphQL transport — an alternative adapter behind the same module/method
surface (largely a
ctx.run-based module on top of the new runner).