I kept rebuilding the same fetch wrapper. So I built api-client instead
There’s a specific kind of tired that comes from writing the same fetch
wrapper for the fifth time. Not the good kind of repetition where you get
faster each time — the kind where you know you’re about to reintroduce a bug
you already fixed in a different repo six months ago. That’s the mood that
led to @developerehsan/api-client, and this post is part one of a short
series on why it exists and what it actually does.
The pattern I kept dragging between projects
Every real app I’ve shipped — across five companies in under two years, plus
side projects like MERN Notes and the browser-based Developer Tools
platform — hits the same wall about two weeks in. You start with a clean
fetch('/api/users') call in a component. Fine. Then you need auth headers.
Then a token expires mid-session and you need a retry-after-refresh flow. Then
two components fire the same request on mount and you’re paying for it twice.
Then someone on the team adds a timeout to one call and forgets the other
twelve. Then production shows a spike of duplicate POSTs because a user
double-clicked a slow button.
None of these are exotic problems. That’s the annoying part. They’re
predictable, they show up in basically every app that talks to a backend,
and yet almost nobody ships a proper answer to all of them at once. You either
pull in a heavy framework-specific solution, or you write a lib/api.ts file
that grows a new if statement every sprint until nobody wants to touch it.
I wrote the JWT rotation and RBAC layer for MERN Notes because I got tired of
auth being an afterthought. api-client is the same instinct pointed at the
request side of things: stop treating caching, retries, dedup, and auth
refresh as things you bolt on when they break. Build the pipeline once,
correctly, and never think about it again.
What actually pushed me over the edge
Real talk: the thing that made me sit down and build this wasn’t one big dramatic bug. It was three small annoyances stacking up in the same week.
- A
stale-while-revalidatecache I’d hand-rolled for one app that didn’t key on the auth token — so two users briefly saw each other’s cached data in a staging environment. Not fun to explain in standup. - An OAuth2 refresh flow where I forgot to coalesce concurrent 401s, so a page with four parallel requests fired four refresh calls at once and occasionally raced itself into a logged-out state.
- Working on the Developer Tools platform, needing an SSR app to call a backend without leaking the base URL and internal paths into the browser bundle — and realizing there wasn’t a clean off-the-shelf way to do that without hand-rolling a proxy route every time.
Each of those is a real bug I’ve actually hit, not a hypothetical. And each
one is small enough that it’s tempting to patch and move on. But patch enough
of these and you end up with a utils/api folder that’s really a distributed,
undocumented, untested version of the thing you should have just built once.
What api-client actually is
It’s a typed client factory. You configure it once, declare modules and methods on it, and every call flows through one pipeline: queue, dedup, cache, resolve auth, dispatch with a timeout, retry on failure, optionally validate against your OpenAPI spec, return.
// src/api.ts
import { createClient, defineModule } from '@developerehsan/api-client'
export const api = createClient({
baseURL: 'https://api.example.com',
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: {
get: async (ctx, id: string) =>
(await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,
},
}),
},
})
const user = await api.users.get('user_42')That’s the whole surface for the common case. No scattered fetch calls, no
manual AbortController bookkeeping, no re-implementing exponential backoff
for the fourth time. The two staging bugs I mentioned above are structurally
impossible here — cache and dedup keys are built from an auth fingerprint and
tenant id automatically, so two users literally cannot share a cached
response, and concurrent 401s during an OAuth2 refresh get coalesced into one
refresh call instead of racing.
Pro tip: the part I actually use daily
The bit I reach for on almost every project isn’t the fancy stuff — it’s the
boring config-layering. Global config, then module config, then per-call
config, deep-merged, later wins. So I set sane defaults once (timeout: 10_000,
three retries, SWR caching) and then override exactly what one flaky payroll
endpoint needs without touching anything else.
The SSR problem, solved without a hand-rolled proxy
This is the one I’m most opinionated about. If you’re building in Next.js and you import your normal API client into a client component, your backend’s base URL and internal paths ship straight into the browser bundle — visible in the Network tab, whether you meant to expose them or not.
api-client ships an RPC bridge for this. You keep the real client — the one
holding your secrets — on the server, and expose an explicit, typed allowlist
of module-and-method pairs. The browser gets a proxy that calls
api.products.getProductById({ id }) exactly like normal, but every call is
actually a same-origin POST carrying { module, method, args }, validated and
authorized server-side before anything touches your real backend. The
compiled browser bundle contains zero backend URLs, paths, or OpenAPI
metadata — just types, erased at build time.
// server: deny-by-default allowlist, module + method names autocomplete
export const rpcHandler = createRpcHandler(api, {
expose: {
pet: ['getPetById', 'findPetsByStatus'],
store: ['getInventory'],
},
})I’ll go deeper into this in a later post because it deserves its own writeup — there’s a whole security model behind it (deny-by-default, prototype pollution guards, CSRF handling per transport) that’s worth walking through properly instead of squeezing into a paragraph here.
Where this series is going
This post is the “why does this exist” part. The next ones will get into the
stuff that’s actually interesting to argue about: how the retry/backoff logic
handles Retry-After headers without turning into a DoS vector against your
own API, how the OpenAPI codegen keeps generated types out of your way instead
of fighting you, and a full walkthrough of the RPC bridge’s security model
since that’s the part I’d want scrutinized the hardest before anyone trusts
it in production.
If you’ve got a lib/api.ts file in one of your projects right now that’s
quietly grown past 300 lines and nobody wants to refactor — that’s exactly the
file this replaces. npm install @developerehsan/api-client and the repo’s
docs/ folder has a page per feature if you want to skip ahead of the series.
- developerehsan