Framework Recipes
Integration guides for React, TanStack Query, SWR, Next.js, Vue, Nuxt, Svelte, and Angular
The client is framework-agnostic. These are integration patterns, not requirements.
TanStack Query (React Query)
Works out of the box — failures reject, which is exactly what the library expects. No per-call flags needed.
// lib/api.ts
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });
// features/users/queries.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
export const userKeys = {
all: ["users"] as const,
lists: () => [...userKeys.all, "list"] as const,
list: (page: number) => [...userKeys.lists(), page] as const,
detail: (id: number) => [...userKeys.all, "detail", id] as const,
};
export function useUsers(page = 1) {
return useQuery({
queryKey: userKeys.list(page),
// `signal` wires react-query cancellation straight through to fetch
queryFn: ({ signal }) => api.get<User[]>("/users", { params: { page }, signal }),
select: (res) => res.data ?? [],
});
}
export function useUser(id: number) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: ({ signal }) =>
api.get<User>("/users/{id}", { addTemplateToUrl: { id }, signal }),
select: (res) => res.data,
enabled: !!id,
retry: (count, error) => {
// Never retry client errors; ApiError carries the status code.
if (error instanceof ApiError && error.statusCode < 500) return false;
return count < 3;
},
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateUser) => api.post<User>("/users", input),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.lists() }),
onError: (e) => {
if (e instanceof ApiError && e.statusCode === 422) setFieldErrors(e.errors ?? {});
},
});
}Global defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (count, error) =>
error instanceof ApiError && error.statusCode < 500 ? false : count < 3,
staleTime: 30_000,
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof ApiError && error.statusCode >= 500) {
toast.error("Something went wrong on our end.");
}
},
}),
});Clearing the cache on sign-out
useEffect(() =>
api.onAuthStateChange((s) => {
if (!s.isAuthenticated) queryClient.clear();
}), [queryClient]);Essential with multi-tab sync: signing out in another tab must not leave user data in this tab's cache.
⚠️ Do not use
throwError: falsewith React Query. A 500 would resolve successfully and be cached as data — no error state, no retry. The repo's verification suite documents this exact hazard.
SWR
import useSWR from "swr";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
const fetcher = <T,>(url: string) => api.get<T>(url).then((r) => r.data);
export function useUsers() {
const { data, error, isLoading } = useSWR<User[], ApiError>("/users", fetcher);
return { users: data ?? [], error, isLoading };
}Because the client rejects on failure, error is a real ApiError and SWR's retry logic works as designed.
<SWRConfig
value={{
fetcher,
onErrorRetry: (error, _key, _cfg, revalidate, { retryCount }) => {
if (error instanceof ApiError && error.statusCode < 500) return;
if (retryCount >= 3) return;
setTimeout(() => revalidate({ retryCount }), 1000 * 2 ** retryCount);
},
}}
>
<App />
</SWRConfig>React without a data library
import { useEffect, useState } from "react";
import { ApiError } from "@mrzr/api-client";
export function useApi<R>(fn: (signal: AbortSignal) => Promise<IRes<R>>, deps: unknown[] = []) {
const [data, setData] = useState<R>();
const [error, setError] = useState<ApiError>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(undefined);
fn(controller.signal)
.then((res) => setData(res.data))
.catch((e) => {
if (e instanceof ApiError && e.canceled) return; // unmounted
setError(e as ApiError);
})
.finally(() => setLoading(false));
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { data, error, loading };
}
// usage
const { data, error, loading } = useApi((signal) => api.get<User[]>("/users", { signal }));Cancelling on navigation and unmount
Enable cancellation once on the client:
export const api = createClient({ baseUrl, cancel: true });React — a scope per component. Everything it started dies with it, no controller to thread through:
function ProductPanel({ id }: { id: string }) {
const scope = useMemo(() => api.cancelScope(`product-${id}`), [id]);
useEffect(() => () => scope.cancel(), [scope]);
useEffect(() => {
scope.get<Product>(`/api/v1/products/${id}`).then(setProduct).catch(skipCanceled);
}, [scope, id]);
}
const skipCanceled = (e: unknown) => {
if (e instanceof ApiError && e.canceled) return;
throw e;
};Next.js App Router:
"use client";
const pathname = usePathname();
useEffect(() => () => api.cancel(), [pathname]);Next.js Pages Router:
router.events.on("routeChangeStart", () => api.cancel());Vue:
const scope = api.cancelScope("product-detail");
onUnmounted(() => scope.cancel());Vue Router:
router.beforeEach((to, from, next) => {
if (to.path !== from.path) api.cancel();
next();
});Svelte:
import { onDestroy } from "svelte";
const scope = api.cancelScope("dashboard");
onDestroy(() => scope.cancel());Angular:
@Component({ /* … */ })
export class ProductComponent implements OnDestroy {
private scope = api.cancelScope("product");
ngOnDestroy() { this.scope.cancel(); }
}TanStack Query / SWR: react-query hands your queryFn a signal — wire it through and it cancels for you. Mutations get no signal and aren't canceled on unmount, so use a scope there.
useQuery({ queryKey: ["users"], queryFn: ({ signal }) => api.get<User[]>("/users", { signal }).then(r => r.data) });Narrow any of these to a single screen's endpoints with a URL pattern — api.cancel("/api/v1/products"). Full recipes for every framework, plus the react-query and SWR specifics: [[Cancellation]].
Next.js — App Router
Server components and route handlers
// lib/server-api.ts
import { createClient } 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,
worker: false, // no Worker on the server
multiTab: false, // keeps the event loop clean
});
const token = (await cookies()).get("access_token")?.value;
if (token) await api.setTokens({ accessToken: token });
try {
return await fn(api);
} finally {
api.destroy();
}
}// app/users/page.tsx
export default async function UsersPage() {
const users = await withApi((api) =>
api.get<User[]>("/users", { cache: "no-store" }).then((r) => r.data ?? []),
);
return <UserList users={users} />;
}Next's fetch cache options pass straight through:
await api.get("/config", { cache: "force-cache" });
await api.get("/feed", { next: { revalidate: 60 } } as never);Client components
// lib/api.ts
"use client";
import { createClient } from "@mrzr/api-client";
export const api = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
storage: "memory",
});Import this only from client components. Server components should use the per-request factory above so tokens are never shared between users.
Never create a module-level client with tokens on the server. Modules are shared across requests, so one user's token could leak into another user's response.
Route handler proxy
Keep tokens entirely server-side by proxying:
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { withApi } from "@/lib/server-api";
export async function GET() {
const users = await withApi((api) => api.get<User[]>("/users").then((r) => r.data));
return NextResponse.json(users);
}The browser then calls /api/users with a same-site httpOnly cookie and never sees the upstream token.
Nuxt 3
// plugins/api.ts
import { createClient } from "@mrzr/api-client";
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const api = createClient({
baseUrl: config.public.apiUrl,
worker: import.meta.client,
multiTab: import.meta.client,
});
return { provide: { api } };
});<script setup lang="ts">
const { $api } = useNuxtApp();
const { data: users, error } = await useAsyncData("users", () =>
$api.get<User[]>("/users").then((r) => r.data ?? []),
);
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<ul v-else><li v-for="u in users" :key="u.id">{{ u.name }}</li></ul>
</template>Nuxt + httpOnly cookie auth
When your Nuxt server routes set httpOnly cookies (/api/auth/*), the API lives
on the same origin as the app, and the tokens are unreadable from JS.
// plugins/api.client.ts ← .client so it never runs during SSR
import { createClient } from "@mrzr/api-client";
export default defineNuxtPlugin(() => {
const api = createClient({
// Same-origin routes: leave baseUrl empty and let them resolve
// against the current origin. Only set it for a separate API host.
authMode: "cookie",
credentials: "include",
loginUrl: "/api/auth/login",
refreshUrl: "/api/auth/refresh",
logoutUrl: "/api/auth/logout",
});
return { provide: { api } };
});Then detect an existing session once, on startup — the cookie survives a reload but is invisible to JS, so the client has to ask:
<script setup lang="ts">
const { $api } = useNuxtApp();
const state = ref(await $api.restoreSession("/api/auth/me"));
async function login() {
await $api.login({ email: email.value, password: password.value });
state.value = await $api.getAuthState(); // now { isAuthenticated: true, user }
}
async function logout() {
await $api.logout();
state.value = await $api.getAuthState();
}
</script>A few things worth getting right here:
- Use
plugins/api.client.ts, notplugins/api.ts. A universal plugin also runs on the server, where each request would build its own client. - You no longer need
baseUrl: window.location.origin. That workaround was necessary before v1.0.2: a Blob worker's base is ablob:URL, which relative paths cannot resolve against, so requests failed in worker mode. The host now falls back to the page origin automatically. Setting it manually still works, but guard it for SSR —windowdoesn't exist there. restoreSession()is what makes reloads work. Without itgetAuthState()reportsfalseon a fresh load even though the cookie is present and requests succeed.- Cookies must reach the server: keep
credentials: "include", and for a separate API origin the server needsAccess-Control-Allow-Credentials: trueplus an explicit (non-*) allowed origin.
Vue 3 (plain)
// lib/api.ts
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ApiError } from "@mrzr/api-client";
import { api } from "@/lib/api";
const users = ref<User[]>([]);
const error = ref<string>();
const loading = ref(true);
onMounted(async () => {
try {
const { data } = await api.get<User[]>("/users");
users.value = data ?? [];
} catch (e) {
error.value = (e as ApiError).message;
} finally {
loading.value = false;
}
});
</script>Prefer no try/catch in components? Create the client with throwError: false and branch on res.status.
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,
});// +page.ts
export const load = async ({ fetch }) => ({
users: (await api.get<User[]>("/users")).data ?? [],
});<script lang="ts">
import { api } from "$lib/api";
let promise = api.get<User[]>("/users");
</script>
{#await promise}
<Spinner />
{:then res}
<UserList users={res.data ?? []} />
{:catch error}
<ErrorBox message={error.message} />
{/await}Angular
// api.service.ts
import { Injectable } from "@angular/core";
import { createClient, type ApiClient } from "@mrzr/api-client";
import { environment } from "../environments/environment";
@Injectable({ providedIn: "root" })
export class ApiService {
readonly client: ApiClient = createClient({ baseUrl: environment.apiUrl });
users() {
return this.client.get<User[]>("/users").then((r) => r.data ?? []);
}
ngOnDestroy() { this.client.destroy(); }
}Bridging to RxJS:
import { from, defer } from "rxjs";
users$ = defer(() => from(this.client.get<User[]>("/users"))).pipe(
map((res) => res.data ?? []),
);Node / scripts / CLIs
import { createClient } from "@mrzr/api-client";
const api = createClient({
baseUrl: process.env.API_URL,
worker: false,
multiTab: false, // important: keeps the process from hanging
timeout: 15_000,
});
try {
await api.login({ email: process.env.EMAIL, password: process.env.PASSWORD });
const { data } = await api.get<Report[]>("/reports");
console.log(data);
} finally {
api.destroy();
}multiTab is auto-disabled on the server anyway (a ref'd BroadcastChannel would keep the event loop alive), but being explicit documents the intent.
Cloudflare Workers / Deno / Bun
export default {
async fetch(request: Request, env: Env) {
const api = createClient({
baseUrl: env.API_URL,
worker: false,
multiTab: false,
});
try {
const { data } = await api.get("/health");
return Response.json(data);
} finally {
api.destroy();
}
},
};Create the client inside the handler, not at module scope — module state is shared across requests and isolates.
Plain browser, no build step
<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");
document.body.textContent = JSON.stringify(data);
</script>Next: [[Cookbook]]