The three concepts you need before you write a single line of api-client
Most people skip the mental model and go straight to copy-pasting a config
object. I get it, that’s usually faster. But api-client has exactly three
ideas underneath it, and if you understand those three ideas first, every
config option in later posts will feel obvious instead of magic. This is
part two of the series — if you haven’t read part one, the short version is:
I got tired of rebuilding the same fetch wrapper in every project, so I built
this instead.
The problem this whole library exists to solve
Here’s what a typical app looks like before something like this exists.
You’ve got a UserProfile component that fetches a user, a UserSettings
component that fetches the same user, and an AdminPanel that also needs it
but with a different timeout because admin endpoints are slower.
// UserProfile.tsx
const res = await fetch(`https://api.example.com/users/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
if (!res.ok) throw new Error('failed')
const user = await res.json()
// UserSettings.tsx — same thing, copy-pasted, slightly different
const res2 = await fetch(`https://api.example.com/users/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})
// ...someone forgot the !res2.ok check here
// AdminPanel.tsx — needs a longer timeout, nobody added it consistently
const controller = new AbortController()
setTimeout(() => controller.abort(), 30_000)
const res3 = await fetch(`https://api.example.com/users/${id}`, {
signal: controller.signal,
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
})Three call sites, three slightly different implementations, and if the token
expires mid-session, all three break in three different ways because none of
them know how to refresh it. This isn’t a hypothetical — this is what every
fetch-based codebase I’ve worked in looks like by month two.
The fix isn’t “write better fetch calls.” It’s “stop writing fetch calls at the component level entirely.” That’s the whole premise. You describe your API once, and every component just calls a typed method.
const user = await api.users.get(id)Same auth, same timeout, same retry behavior, same caching, everywhere, automatically. That one-liner is doing everything the three messy blocks above were trying to do — plus catching bugs they didn’t even know they had.
Concept 1 — the client
The client is the thing you create exactly once, usually in a file like
src/api.ts, and import everywhere else. It holds your global settings
(base URL, auth strategy, default timeout, cache config) and it holds shared
state — the cache itself, the in-flight request map used for deduplication,
the concurrency queue.
That last part matters more than it sounds like it should. Because the client holds shared state, two different components calling the exact same endpoint at the exact same moment don’t fire two network requests — the second one just waits for the first one’s result. You get that for free, just by having one client instance instead of one fetch call per component.
Problem it solves: duplicate in-flight requests, inconsistent config across call sites, and “where do I even put the auth header logic” confusion.
How: one createClient() call, imported everywhere, never recreated per
component.
// src/api.ts — created once
import { createClient } from '@developerehsan/api-client'
export const api = createClient({
baseURL: 'https://api.example.com',
auth: { strategy: 'bearer', getToken: () => localStorage.getItem('access_token') },
})// anywhere else in your app
import { api } from './api'A mistake I made early on: creating the client inside a component, thinking
of it like any other object. Don’t do that — you lose the shared cache and
dedup state, and you’re basically back to plain fetch with extra steps.
Concept 2 — modules and methods
A module is just a named group of related endpoints — users,
invoices, orders. You declare one with defineModule, and inside it you
write methods, which are the actual functions your app calls.
Problem it solves: without modules, you either end up with one giant flat
object of every endpoint (api.getUser, api.getUserSettings,
api.updateUserSettings…) or you’re back to scattering raw URLs through
your codebase. Modules give you a namespace that mirrors how your backend is
actually organized.
import { defineModule } from '@developerehsan/api-client'
const users = defineModule({
methods: {
get: async (ctx, id: string) =>
(await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,
list: async (ctx, params?: { page?: number }) =>
(await ctx.request({ method: 'GET', path: '/users', query: params })).data,
create: async (ctx, body: { name: string; email: string }) =>
(await ctx.request({ method: 'POST', path: '/users', body })).data,
},
})Notice every method’s first argument is ctx — that’s the module
context, and it’s injected for you automatically. You never pass it
yourself. Callers just do api.users.get('user_42'); the library slots ctx
in as the first argument behind the scenes. This trips people up exactly
once — the method signature has ctx as the first parameter, but the call
site never mentions it.
ctx.request(spec) is the one primitive every method eventually calls. It
takes an HTTP method, a path (with {placeholders} for path params), and
optional query/body — and it’s the doorway into concept three.
A quick problem/solution on path params
Problem: you write path: '/orders/{orderId}/lines/{lineId}' but forget
to pass lineId in pathParams.
Solution: the library throws a ConfigurationError before any network
call happens — not a confusing 404 from a malformed URL, not a silent
undefined in the path string. You find out immediately, at the call site,
with a clear error. That’s a small thing, but it’s the difference between a
five-second fix and a twenty-minute “why is this URL wrong” debugging
session.
Concept 3 — the pipeline
This is the one that actually does the work. Every single ctx.request(...)
call — no matter which method, which module, which part of your app —
flows through the same ordered sequence of steps before a response comes
back.
flowchart TD
A[Your call: api.users.get] --> B[Merge config:<br/>global to module to per-call]
B --> C[Concurrency queue]
C --> D[Dedup check]
D --> E{Cache hit?}
E -->|Yes, fresh| F[Return cached data]
E -->|No, or stale| G[Resolve tenant and auth headers]
G --> H[Send request with timeout]
H --> I{Failed?}
I -->|5xx, network, timeout| J[Retry with backoff]
J --> H
I -->|401| K[Refresh token, retry once]
K --> H
I -->|No| L[Validate response, if enabled]
L --> M[Write to cache]
M --> N[Return typed response]Every box on that diagram is a real, separate feature — caching, dedup, retries, auth refresh, validation — and every one of them is going to get its own full post later in this series. What matters right now is just the order, because the order is what makes the whole thing safe by default.
Problem it solves: in a hand-rolled setup, these steps get implemented inconsistently — one dev adds retries to their fetch call, another forgets, someone adds caching that doesn’t account for auth changing. Here, the order is fixed and shared by every single request in your app, so you can’t accidentally skip a step.
Concrete example of why order matters: cache lookup happens after dedup but before auth resolution finishes. That’s deliberate — the cache key needs to include an auth fingerprint (so two users never share a cached response), but you don’t want to do a full auth resolution for a request that’s about to be served from cache anyway. Get that ordering wrong in a hand-rolled implementation and you get subtle cross-user data leaks — which is exactly the kind of bug that’s cheap to write and expensive to find in production.
Installation
Nothing exotic here, three peer-dependency decisions and you’re done.
# pick your package manager
pnpm add @developerehsan/api-client
npm install @developerehsan/api-client
yarn add @developerehsan/api-clientThe library ships with zero required dependencies bundled in — you opt into what you need, and unused pieces add zero bytes to your build:
# Axios adapter (the default). Skip it entirely if you're fine running on fetch.
pnpm add axios
# TanStack Query integration — install only the framework entry you use
pnpm add @developerehsan/api-client-query @tanstack/react-queryYou need TypeScript 5+ with strict mode (strongly recommended, not just
tolerated), and Node 18+ — or any modern browser, or an edge runtime like
Vercel Edge or Cloudflare Workers. That last part matters more than it
sounds: the library detects edge environments automatically and switches to
the fetch adapter so it never tries to load Axios somewhere Axios can’t
run.
Your first client, actually explained line by line
Here’s the canonical “five minute” example, and instead of just pasting it, I want to walk through why each line exists.
// src/api.ts
import { createClient, defineModule } from '@developerehsan/api-client'
export const api = createClient({
baseURL: 'https://api.example.com',
openapi: { mode: 'runtime' },
auth: {
strategy: 'bearer',
getToken: () => localStorage.getItem('access_token'),
},
http: { timeout: 10_000, retry: { attempts: 3 } },
cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },
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,
},
}),
},
})baseURL— every path you write later (/users,/users/{id}) gets joined onto this. Change it once here, not in forty scattered strings.openapi: { mode: 'runtime' }— this is the simplest possible mode. It means the client can fetch your OpenAPI spec at runtime for response validation, without you having to run a codegen step first. Later posts covercodegenmode, which trades this simplicity for compile-time type safety — worth it once your API stabilizes, overkill on day one.auth.getToken— this function is called before every authenticated request. It can be sync or async, which matters if your token lives in secure storage that requires a read.http.timeoutandretry.attempts— defaults for every request in the app, unless a specific module or call overrides them. This is the setting that would have caught theAdminPaneltimeout bug from the intro example — you set it once, centrally, instead of remembering it per component.cache.strategy: 'stale-while-revalidate'— return a cached response immediately if one exists, refresh it in the background, and keep serving the stale copy if the background refresh fails. Good default for most UI data; later posts cover whencache-firstornetwork-firstare the better call.
And using it, anywhere else in the app:
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’s it. That’s a fully working client with auth, retries, timeouts, caching, and deduplication already active on every single call — not because you wired each of those up individually, but because they’re properties of the pipeline every request already flows through.
The mistake almost everyone makes on day one
Problem: you write a method, call it, and get
api.myModule.myMethod is not a function.
Why it happens: people assume that because the library supports OpenAPI codegen, the codegen step generates runtime methods too — so they expect a method to “just exist” once they’ve pointed the client at a spec.
Solution: it doesn’t work that way, and understanding why ties back
directly to concept two. Codegen generates types and a module
descriptor map — compile-time safety and metadata for the TanStack Query
integration. The actual runtime methods, the functions your code calls, are
always the ones you write with defineModule, usually as thin wrappers
around ctx.request(...) that reference the generated paths. This is a
deliberate design choice: it keeps runtime behavior explicit and debuggable.
If a method exists, you can always find it, because you wrote it — nothing
is conjured from a spec file behind your back.
What sticks from this post
Three ideas, and everything later in this series is really just detail underneath them:
- The client is created once and holds shared state — that’s what makes dedup and caching actually work across your whole app instead of per component.
- Modules and methods are your typed, organized entry points —
ctxis injected, you never pass it, andctx.request(...)is the one primitive underneath every method you’ll ever write. - The pipeline is a fixed, ordered sequence every request goes through — queue, dedup, cache, auth, dispatch, retry, validate, return — and the order is what makes the defaults safe instead of just convenient.
Next up in the series: authentication, all four strategies, and the OAuth2 refresh flow in enough detail to actually trust it in production — including the sequence diagram for how concurrent 401s get coalesced into a single refresh instead of racing each other.
- developerehsan