React
Provider, typed hooks and the server session.
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BetterSupabaseProvider } from "better-supabase/react";
import { bs } from "@/lib/supabase/client";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BetterSupabaseProvider client={bs} queryClient={queryClient}>
{children}
</BetterSupabaseProvider>
</QueryClientProvider>
);
}Create hooks typed for your schema once:
import { createHooks } from "better-supabase/react";
import type { bs } from "./supabase/client";
export const { useDb, useQueries, useAuth, useSupabase } =
createHooks<typeof bs>();function CustomerList() {
const q = useQueries();
const { data } = useQuery(q.customers.findMany({ select: ["id", "name"] }));
const auth = useAuth();
if (auth.status === "signed-out") return <SignIn />;
return (
<ul>
{data?.map((c) => (
<li key={c.id}>{c.name}</li>
))}
</ul>
);
}useAuth()rendersloadingon the server and during hydration, then the real state. It usesuseSyncExternalStore, so it never tears.useDb()anduseQueries()re-render when the user changes.- With
queryClient, the provider removes all better-supabase queries when the user signs out or switches accounts, and refetches them when the token's tenant or role changes, so one user's or tenant's data never shows up for the next. It usesclearOnUserChange. useBroadcast(topic, values, handlers, { invalidate })subscribes to a Realtime topic while mounted and refetches the listed tables after each message.
Auth, search, storage and presence hooks
These hooks replace the code most screens write by hand. Each reads the
provider's client, or takes { client: supabase } without the provider.
"use client";
import { useSignIn } from "better-supabase/react";
export function SignInForm() {
const signIn = useSignIn({ onSuccess: () => router.push("/") });
return (
<form
action={(form) =>
signIn.password({
email: String(form.get("email")),
password: String(form.get("password")),
})
}
>
{signIn.error ? <p role="alert">{signIn.error.message}</p> : null}
<button disabled={signIn.pending}>Sign in</button>
</form>
);
}| Hook | What it returns |
|---|---|
useSignIn() | password, otp, verifyOtp and oauth, with pending and the last error |
useSignOut() | signOut(options), with pending and error; the provider then clears cached queries |
useDebouncedSearch(initial, { delayMs }) | value and setValue for the input, the settled term, and pattern with % and _ escaped for ilike |
useSignedUrl(bucket, target, { ttl }) | a signed URL that is signed again before it expires; null pauses |
useUpload(bucket) | upload(target, body), progress from 0 to 1, status, abort and reset |
usePresence(topic, values, { state }) | the members on a presence topic, track and untrack |
useDebouncedSearch pairs with contains, which escapes the term itself:
where: search.term ? { name: { contains: search.term } } : {}. escapeLike
is exported for filters you build yourself.
useUpload reports real progress where XMLHttpRequest exists (browsers
and React Native): the upload goes to a signed upload URL, and abort
cancels the request. Elsewhere, and for uploads with metadata, progress
jumps to 1 when the upload finishes. The same onProgress option works on
bucket.upload() directly.
usePresence tracks the state option after the join and again when its
JSON changes; pass null to untrack. It rejoins when the user changes.
Server Actions
useAction and useActionForm call a bs.action()
Server Action and track its ActionResult, so a component doesn't need its
own transition, pending flag and error state.
"use client";
import { useActionForm } from "better-supabase/react";
import { createCustomer } from "../customer-actions";
export function CreateCustomerForm() {
const form = useActionForm(createCustomer, {
onSuccess: (customer) => toast.success(`${customer.name} added`),
});
return (
<form {...form.formProps}>
<Input name="name" aria-invalid={form.fieldErrors.name !== undefined} />
{form.fieldErrors.name ? <p>{form.fieldErrors.name}</p> : null}
<Button type="submit" disabled={form.pending}>
Add
</Button>
</form>
);
}useActionForm(action, { onSuccess, onError, resetOnSuccess })submits the form'sFormData. The fields keep what the user typed when the action fails and clear on success (resetOnSuccess: falsekeeps them, for an edit form).fieldErrorsholds the first message per field of avalidationerror;fieldErrorsOf(error)returns the same for anyDbError.useAction(action, { onSuccess, onError })runs the action from an event handler withrun(input), which resolves with theActionResult. The input type dropsFormData, soruntakes the action's object input.pendingcovers the run and the re-render it causes,dataanderrorhold the last result, andpendingInputslists the inputs in flight, so one table row can show its own change before the server confirms it:
const roleChange = useAction(updateMemberRole);
const roleOf = (member: Member) =>
roleChange.pendingInputs.findLast((input) => input.userId === member.userId)
?.role ?? member.role;- A thrown error (not an
ActionResulterror) still reaches the nearest error boundary.
createErrorMessages(messages) from better-supabase turns a DbError
into the sentence to show. It takes a message, or a function of the error,
for every error kind; leaving a kind out is a type
error, so a new kind can't fall through to a generic message. Build it
where your translations are:
import { createErrorMessages } from "better-supabase";
export function useErrorMessage() {
const t = useExtracted("errors");
return createErrorMessages({
unauthorized: t("Sign in again to continue."),
forbidden: t("You don't have access to this."),
raised: (error) => error.message,
// ...every other kind
});
}Server session
useAuth() reads the browser client, so it is loading during server
rendering. To render signed-in UI on the server, hand Client Components the
server-verified session instead: pass a bs.session() promise to
SessionProvider and read it with useSession().
import { SessionProvider } from "better-supabase/react";
<SessionProvider sessionPromise={getSession()}>
<SideNav />
</SessionProvider>;"use client";
import { useSession } from "better-supabase/react";
export function SideNav() {
const session = useSession(); // suspends until the promise resolves
return session.kind === "user" ? <Menu claims={session.claims} /> : null;
}SessionProviderrenders from Server Components: thereact-serverbuild exports it as a client reference.useSession()suspends, so keep it under a<Suspense>boundary, and create the promise inside that boundary.- The session is a plain
AuthSession(no token), the same typebs.session()returns.tenantOf(session)reads its active tenant (the tenant claim at the top level of the token, then inapp_metadata), on the server and in the browser.
See Cache Components for the full pattern, including role-filtered menus.
A tenant from the URL
When the tenant is part of the route (/[organizationId]/customers), the
browser client doesn't read the URL: useDb(), useQueries() and the live
hooks scope tenant tables to the tenant_id claim. Resolve the tenant on the
server and hand it down:
- Read the rows in a Server Component with
bs.context({ tenant: organizationId }), or in a'use cache: private'function that takesorganizationIdas an argument and callsbs.cached({ tenant: organizationId }). Pass the rows to the Client Component. - Pass
organizationIdto the Client Component as a prop, and send it in the action's input. The action scopes its context withtenant: (input) => input.organizationId, so a tenant the caller doesn't belong to grants nothing, the same as a tenant from the resolver. - Pass the same id to the live hooks:
useLiveQuery(spec, { tenant })anduseLiveCount(seed, { tenant }). Seed the count with thedbfrom the scoped context:bs.liveCount(spec, db).
export default async function Customers({
params,
}: PageProps<"/[organizationId]/customers">) {
const { organizationId } = await params;
const customers = await getCustomers(organizationId);
return <CustomerList organizationId={organizationId} customers={customers} />;
}"use client";
export function CustomerList({ organizationId, customers }: Props) {
useLiveQuery(customersSpec, { tenant: organizationId });
const add = (name: string) => createCustomer({ organizationId, name });
// ...
}"use server";
export const createCustomer = bs.action(
{ input: CreateCustomer, tenant: (input) => input.organizationId },
(input, { db }) =>
db.customers.create({ name: input.name }, { select: ["id"] }),
);The Realtime policy still decides which tenant topics a user may join (see live queries).
Last updated on