cd ~/blog
|9 min read

Better Auth + TanStack Query: Full Caching, Zero Boilerplate

Better AuthTanStack QueryReactTypeScriptAuth

Better Auth gives you a fully typed client for auth, organizations, invitations, and billing. TanStack Query gives you caching, request deduplication, background refetching, and data / isPending / error states for free. They don't speak the same language out of the box — but one small option bridges them, and once you wire it up you can delete every useState(loading) and toast.error(...) scattered across your components.

The impedance mismatch

Better Auth client methods never throw. They resolve to a { data, error } pair:

example.ts
const { data, error } = await authClient.organization.list();
if (error) {
  // handle it
}

TanStack Query works the opposite way: it decides isError by whether your queryFn throws. So the naive integration hand-unwraps every call and flattens typed errors into generic Errors, repeated in every component:

naive.tsx
const { data, isPending } = useQuery({
  queryKey: ["studios"],
  queryFn: async () => {
    const { data, error } = await authClient.organization.list();
    if (error) throw new Error(error.message); // manual re-throw, loses the error shape
    return data;
  },
});

The bridge: fetchOptions: { throw: true }

Better Auth is built on better-fetch, and every client method accepts a fetchOptions object. Setting throw: true flips the method into exactly the shape TanStack Query wants — it returns the data directly and throws on failure:

bridge.ts
const orgs = await authClient.organization.list({
  fetchOptions: { throw: true },
});
// orgs is Organization[] — not { data, error }

The types follow along: TypeScript knows the return is unwrapped, so your queryFn needs no assertions and no manual re-throw. The thrown error is a BetterFetchError carrying the HTTP status and the parsed server body ({ code, message }) — which matters later for global error handling.

Diagram showing Better Auth returning a data-error pair on the left, the fetchOptions throw-true option as a bridge in the middle, and TanStack Query receiving unwrapped data or a thrown BetterFetchError on the right

One warning: you can set throw: true globally in createAuthClient, but don't. It changes the return shape of every call, including sign-in forms where you want to inspect error.code inline. Opt in per call instead.

Setting up the QueryClient

Wrap your app once:

providers/query-provider.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: false, // auth errors (401/403) won't fix themselves on retry
      staleTime: 30_000, // serve cached data instantly, refetch in background
    },
  },
});

export function QueryProvider({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

staleTime is the caching lever most people miss. With the default of 0, every mount refetches. With 30_000, a component that remounts within 30 seconds renders instantly from cache — and TanStack Query still revalidates in the background when the data goes stale.

Query hooks: one file, shared keys

Keep every auth-related query in a dedicated module, and export the query keys so mutations can invalidate them without magic strings:

query/user.query.ts
"use client";

import { useQuery } from "@tanstack/react-query";
import { authClient } from "@/lib/auth-client";

export const userKeys = {
  studios: ["my-studios"] as const,
  invitations: ["user-invitations"] as const,
  accounts: ["linked-accounts"] as const,
};

export function useMyStudios() {
  return useQuery({
    queryKey: userKeys.studios,
    queryFn: () =>
      authClient.organization.list({ fetchOptions: { throw: true } }),
  });
}

export function useUserInvitations() {
  return useQuery({
    queryKey: userKeys.invitations,
    queryFn: async () => {
      const invites = await authClient.organization.listUserInvitations({
        fetchOptions: { throw: true },
      });
      return invites.filter(
        (invite) => new Date(invite.expiresAt).getTime() > Date.now(),
      );
    },
  });
}

Components collapse to a single line of data-fetching:

studios-card.tsx
function StudiosCard() {
  const { data: studios = [], isPending } = useMyStudios();

  if (isPending) return <Skeleton />;
  return studios.map((s) => <StudioRow key={s.id} studio={s} />);
}

You get deduplication for free here too: if three components on the page call useMyStudios(), TanStack Query fires one network request and shares the result.

Diagram showing three components — StudiosCard, Sidebar, and HeaderMenu — all calling useMyStudios and converging on a single query cache keyed by my-studios, which fires one request to the auth API

For parameterized queries, make the key a function:

query/auth.query.ts
export const authKeys = {
  teams: (orgId: string) => ["teams", orgId] as const,
  teamsAll: ["teams"] as const, // prefix — invalidates every org's teams
};

export function useTeams(orgId: string) {
  return useQuery({
    queryKey: authKeys.teams(orgId),
    queryFn: () =>
      authClient.organization.listTeams({
        query: { organizationId: orgId },
        fetchOptions: { throw: true },
      }),
  });
}

Mutations that keep the cache honest

The same pattern works for writes. Put cache invalidation inside the hook so every caller gets it automatically, and leave contextual side effects (success toasts, navigation, closing dialogs) to the component via mutate's options:

query/user.query.ts
export function useAcceptInvitation() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (invitationId: string) =>
      authClient.organization.acceptInvitation({
        invitationId,
        fetchOptions: { throw: true },
      }),
    onSuccess: () => {
      void queryClient.invalidateQueries({ queryKey: userKeys.invitations });
      void queryClient.invalidateQueries({ queryKey: userKeys.studios });
    },
  });
}
invite-row.tsx
function InviteRow({ invite }: { invite: UserInvitation }) {
  const accept = useAcceptInvitation();

  return (
    <Button
      disabled={accept.isPending}
      onClick={() =>
        accept.mutate(invite.id, {
          onSuccess: () => toast.success(`Welcome to ${invite.organizationName}`),
        })
      }
    >
      {accept.isPending ? "Joining…" : "Accept"}
    </Button>
  );
}
Diagram of a mutation: the component calls mutate, the mutationFn resolves with throw-true, then on success the hook's onSuccess invalidates queries first, followed by the component's onSuccess handling the toast and navigation

No useState(busy), no manual refresh callbacks threaded through props. isPending is the busy flag; invalidation refetches exactly the queries that changed. Both callbacks fire, in order: the hook's onSuccess (invalidation) runs first, then the one passed to mutate (toast). That split is the whole architecture — cache correctness lives in the hook, UX lives in the component.

Global error handling

TanStack Query lets you attach onError at the cache level, which means one place handles every failed query and mutation in the app:

query-client.ts
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";

// Better Auth throw-mode errors carry the parsed server body on `.error`
function errorMessage(err: unknown): string {
  const e = err as { error?: { message?: string } | null; message?: string };
  return e.error?.message || e.message || "Something went wrong";
}

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => toast.error(errorMessage(error)),
  }),
  mutationCache: new MutationCache({
    onError: (error) => toast.error(errorMessage(error)),
  }),
  defaultOptions: {
    queries: { refetchOnWindowFocus: false, retry: false },
  },
});

Note the errorMessage helper: a BetterFetchError puts the server's { code, message } on its .error property, so the user sees "You are not allowed to invite members" from the server instead of a generic "Request failed". With this in place, the per-component error handling disappears entirely — and a component that needs custom behavior can still pass onError to mutate, and both handlers run.

Where TanStack Query is the wrong tool

1. The session. Better Auth ships its own reactive hook:

session.tsx
const { data: session, isPending, error } = authClient.useSession();

It's backed by nanostores and updates reactively — sign in, sign out, or refresh anywhere and every subscriber re-renders. Wrapping getSession in useQuery loses that. Same goes for plugin hooks like useActiveOrganization — they already refresh themselves. Use TanStack Query for request/response data (lists, invitations, members, billing); leave the live session state to Better Auth.

2. Expected failures. Some "errors" are really UI states. An invitation lookup that 404s means show the expired screen, not toast an error. For those, skip throw: true and translate deliberately:

query/subscription.query.ts
export function useActiveSubscription(orgId?: string) {
  return useQuery({
    queryKey: ["subscription", orgId ?? "none"],
    enabled: !!orgId, // don't fire until we know the org
    queryFn: async () => {
      const { data, error } = await authClient.subscription.list({
        query: { referenceId: orgId!, customerType: "organization" },
      });
      if (error) return null; // billing not configured reads as "no plan"
      return data?.find((s) => s.status === "active") ?? null;
    },
  });
}

The enabled flag is another quiet performance win — dependent queries wait for their inputs instead of firing, failing, and refetching.

The payoff

  • One request per screen, not per component — identical keys are deduplicated, and staleTime serves repeat visits from cache.
  • isPending / error / data everywhere — no hand-rolled loading state, no busy booleans.
  • Errors handled once — cache-level onError with real server messages, courtesy of BetterFetchError.
  • Writes keep reads fresh — mutations invalidate by shared key, so the UI never shows stale members, invites, or plans.
  • Fully typed end to end throw: true unwraps at the type level, so data is the real payload type with zero casts.

The whole integration is one option (fetchOptions: { throw: true }), one provider, and a couple of hook files. Everything else is TanStack Query doing what it already does best.