ehsan.blog
~/blog/why-i-built-api-client-npm-package-to-automate-the-fetch-requests — zsh
cat why-i-built-api-client-npm-package-to-automate-the-fetch-requests.md

I kept rebuilding the same fetch wrapper. So I built api-client instead

·6 min read

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

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

ts
// 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.