@mrzr/api-client

Frameworks

Use @mrzr/api-client with TanStack Query, SWR, React, Next.js App Router and Vue. Short, copy-paste setups for each, including server rendering.

The client works with any framework. It sits under your data library and replaces your fetch or axios layer, not TanStack Query or SWR.

TanStack Query

Failures throw, which is what Query expects. Pass signal so Query can cancel requests:

export function useUsers(page = 1) {
  return useQuery({
    queryKey: ["users", page],
    queryFn: ({ signal }) =>
      api.get<User[]>("/users", { params: { page }, signal }).then((r) => r.data),
  });
}

export function useCreateUser() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (input: NewUser) => api.post<User>("/users", input),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
  });
}

Don't retry client errors, and clear the cache when the session ends, including when the user logs out in another tab:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (count, error) => (error instanceof ApiError && error.statusCode < 500 ? false : count < 3),
    },
  },
});

api.onAuthStateChange((s) => {
  if (!s.isAuthenticated) queryClient.clear();
});

Keep the default throwError: true. With false, a 500 resolves normally and Query caches it as data.

SWR

const fetcher = (url: string) => api.get(url).then((r) => r.data);
const { data, error } = useSWR<User[]>("/users", fetcher);

Next.js (App Router)

In the browser, create one client in a client-only module:

lib/api.ts
"use client";
import { createClient } from "@mrzr/api-client";

export const api = createClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL });

On the server, create a client per request and destroy it afterwards. A module-level client would share one user's token with everyone:

lib/server-api.ts
import { createClient, type ApiClient } from "@mrzr/api-client";
import { cookies } from "next/headers";

export async function withApi<T>(fn: (api: ApiClient) => Promise<T>): Promise<T> {
  const api = createClient({ baseUrl: process.env.API_URL, multiTab: false });
  const token = (await cookies()).get("access_token")?.value;
  if (token) await api.setTokens({ accessToken: token });
  try {
    return await fn(api);
  } finally {
    api.destroy();
  }
}

The Web Worker is skipped on the server automatically. Next's cache options pass straight through to fetch.

Vue

import { ref, onUnmounted } from "vue";
import { api } from "@/lib/api";

export function useAuth() {
  const isAuthenticated = ref(false);
  api.getAuthState().then((s) => (isAuthenticated.value = s.isAuthenticated));
  const off = api.onAuthStateChange((s) => (isAuthenticated.value = s.isAuthenticated));
  onUnmounted(off);
  return { isAuthenticated };
}

Nuxt

Create the client in a client-only plugin, so the server doesn't build one per request and there's one shared session in the browser:

plugins/api.client.ts
import { createClient } from "@mrzr/api-client";

export default defineNuxtPlugin(() => {
  const api = createClient({ baseUrl: useRuntimeConfig().public.apiUrl });
  return { provide: { api } };
});
<script setup lang="ts">
const { $api } = useNuxtApp();
const { data: users } = await useAsyncData("users", () => $api.get<User[]>("/users").then((r) => r.data ?? []));
</script>

With httpOnly cookies set by Nuxt server routes, leave baseUrl out (same origin), use authMode: "cookie" and point loginUrl, refreshUrl and logoutUrl at your /api/auth/* routes. Call $api.restoreSession("/api/auth/me") on startup.

SvelteKit

src/lib/api.ts
import { createClient } from "@mrzr/api-client";
import { browser } from "$app/environment";
import { PUBLIC_API_URL } from "$env/static/public";

export const api = createClient({ baseUrl: PUBLIC_API_URL, worker: browser, multiTab: browser });
{#await api.get<User[]>("/users")}
  <Spinner />
{:then res}
  <UserList users={res.data ?? []} />
{:catch error}
  <p>{error.message}</p>
{/await}

Angular

@Injectable({ providedIn: "root" })
export class ApiService implements OnDestroy {
  readonly client = createClient({ baseUrl: environment.apiUrl });

  users$ = defer(() => this.client.get<User[]>("/users")).pipe(map((r) => r.data ?? []));

  ngOnDestroy() {
    this.client.destroy();
  }
}

Node, scripts and edge runtimes

The worker and tab sync switch off on the server by themselves; passing worker: false, multiTab: false just makes that explicit. Destroy clients you create per run or per request.

const api = createClient({ baseUrl: process.env.API_URL, worker: false, multiTab: false });
try {
  await api.login({ email: process.env.EMAIL, password: process.env.PASSWORD });
  const { data } = await api.get<Report[]>("/reports");
} finally {
  api.destroy();
}

In Cloudflare Workers, Deno and Bun, create the client inside the request handler, not at module scope: module state is shared between requests.

React Native

There's no Web Worker or BroadcastChannel, so pass worker: false, multiTab: false, and give it a storage adapter backed by AsyncStorage or SecureStore to survive app restarts.

Without a bundler

<script type="module">
  import { createClient } from "https://esm.sh/@mrzr/api-client";

  const api = createClient({ baseUrl: "https://api.example.com" });
  const { data } = await api.get("/health");
</script>

Setting baseUrl with environment variables

If you leave out baseUrl, the client reads the first of these that is set: NEXT_PUBLIC_API_URL, NEXT_PUBLIC_API_BASE_URL, NEXT_PUBLIC_BASE_URL, VITE_API_URL, VITE_API_BASE_URL, VITE_BASE_URL, NUXT_PUBLIC_API_URL, REACT_APP_API_URL, EXPO_PUBLIC_API_URL, PUBLIC_API_URL, PUBLIC_API_BASE_URL, API_URL, API_BASE_URL. In a browser with none set, it uses the page's origin.

Only variables your bundler exposes to the browser work there: API_URL is fine on the server but stripped from a browser bundle, so use a prefixed name like NEXT_PUBLIC_API_URL or VITE_API_URL. Restart the dev server after editing .env.

If the URL is only known at runtime (loaded from a config file after the bundle was built), set globalThis.__API_BASE_URL__ before creating the client. It wins over every variable. detectBaseUrl() shows what the client would pick; "" means nothing was found.

Passing it explicitly is clearer: createClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL }).

On this page