React + React Native hooks for Supabase, with offline-first support, powered by @tanstack/react-query.
One universal @supabase/supabase-js client covers the database, auth, storage, realtime, functions, and RPC. This library wraps it in a uniform, fully-typed hook API: useRow/useRows CRUD, optimistic updates, a fluent filter builder, realtime subscriptions, a persisted query cache, and a paused-mutation queue that replays on reconnect.
- Install
- Quick start
- The factory:
createSupabaseQuery<Database>() - Data hooks (
db/) - Filtering with the QueryBuilder
- Realtime
- Access control is RLS, not permissions
- Offline-first
- Auth
- Storage
- Edge Functions & RPC
- Teams (library-owned module)
- Push notifications (library-owned module)
- Installing a library-owned module (SQL + function + secrets)
- React Native setup
- API reference
bun add @zeroin.earth/supabase-query @supabase/supabase-js @tanstack/react-query
# or: npm i / pnpm add / yarn addPeer dependencies:
| Package | Required on | Notes |
|---|---|---|
@supabase/supabase-js |
all | The one universal client. |
@tanstack/react-query |
all | The query/mutation engine. |
react |
all | |
@react-native-async-storage/async-storage |
RN only | GoTrue session storage and the offline cache. |
@react-native-community/netinfo |
RN only | Network-state adapter for offline queuing. |
react-native-url-polyfill |
RN only | supabase-js needs a URL polyfill on RN. |
The RN peers are optional — install them only for the React Native entry (@zeroin.earth/supabase-query/react-native).
1. Generate your Database type (once, and after every schema change):
supabase gen types typescript --local > src/database.types.ts
# or against the cloud: --project-id <ref>2. Build the typed hooks once, in one module:
// src/lib/supabase.ts
import { createSupabaseClient, createSupabaseQuery } from '@zeroin.earth/supabase-query'
import type { Database } from '../database.types'
export const client = createSupabaseClient<Database>({
url: process.env.EXPO_PUBLIC_SUPABASE_URL!,
anonKey: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
})
export const {
SupabaseProvider,
useRow,
useRows,
useCreateRow,
useUpdateRow,
useDeleteRow,
useUser,
useLogin,
// …every hook, bound to your Database type
} = createSupabaseQuery<Database>()3. Wrap your app:
import { SupabaseProvider, client } from './lib/supabase'
export default function App() {
return (
<SupabaseProvider client={client}>
<Todos />
</SupabaseProvider>
)
}4. Use the hooks — table names autocomplete, row types infer, no generics:
import { useRows, useCreateRow } from './lib/supabase'
function Todos() {
const { rows, total, isPending } = useRows('todos', (q) => q.eq('done', false).order('created_at'))
const { mutate: createTodo } = useCreateRow()
if (isPending) return <Spinner />
return (
<>
<p>{total} open</p>
{rows?.map((t) => <TodoItem key={t.id} todo={t} />)}
<button onClick={() => createTodo({ table: 'todos', values: { title: 'New' } })}>Add</button>
</>
)
}Call the factory once per app with your generated Database type. It returns the platform-correct SupabaseProvider plus the entire hook set, each hook bound to your schema:
- Table names autocomplete against your real tables.
- Row types infer —
useRow('todos', id)gives youTodo, no<Row>generic. - Filters are checked against real columns.
The db/ and functions/ RPC hooks are Database-typed; the fixed-shape modules (auth/, storage/, teams/, push/) are library-owned and not schema-parameterized.
You can also import any hook standalone (e.g. import { useRows } from '@zeroin.earth/supabase-query') and pass row types explicitly — but the factory is the ergonomic path.
Advanced client: build your own createClient<Database>(…) and pass it as <SupabaseProvider client={{ supabase }}> if you need custom options.
Postgres has one table API, so the vocabulary is schema → table → row.
| Hook | Purpose |
|---|---|
useRow(table, id, opts?) / useSuspenseRow |
Read one row (.single()), live via realtime. |
useRows(table, builder?, opts?) / useSuspenseRows |
Read a list; returns { rows, total } (count:'exact'). |
useInfiniteRows(table, builder?, opts?) |
Keyset-paginated infinite scroll. |
useRowsWithPagination(table, builder?, opts?) |
Offset/range() pagination. |
useCreateRow() |
Insert; seeds the row cache on success. { table, values }. |
useUpdateRow() |
Partial update; optimistic. { table, id, values }. |
useUpsertRow() |
Upsert with onConflict. { table, values, onConflict }. |
useDeleteRow() |
Delete; optimistic. { table, id }. |
useIncrementColumn() / useDecrementColumn() |
Atomic col = col ± n via an RPC; optimistic (see RPC). |
Reads default to schema: 'public', select: '*', and subscribe: true (live realtime). Rows are plain typed column objects; the primary key is a first-class id column.
Every mutation variable carries table (and optional schema) so the offline replay queue can reconstruct the call from persisted variables alone.
const { mutate: update } = useUpdateRow()
update({ table: 'todos', id, values: { done: true } }) // optimistic; rolls back on errorWhat "optimistic" covers. The patch lands in the single-row cache and in every cached useRows list holding that row — including the lists behind useInfiniteRows and useRowsWithPagination. Lists are what a screen usually renders, and a mutation paused offline never reaches onSettled to invalidate anything, so a row-key-only patch would make an offline edit invisible until reconnect.
One thing it can't do: re-evaluate a list's filters. useRows('todos', q => q.eq('done', false)) keeps showing a row you just patched to done: true until the post-mutation refetch lands. Filter it client-side if that gap matters:
const { rows } = useRows('todos', (q) => q.eq('done', false))
const visible = rows?.filter((t) => !t.done) ?? []useRows/useInfiniteRows/useRowsWithPagination take a fluent builder as their second argument. It records a serializable descriptor that is both hashed into the query key (so keys stay stable) and replayed onto the PostgREST query:
useRows('posts', (q) =>
q.eq('published', true)
.in('author_id', authorIds)
.ilike('title', '%supabase%')
.order('created_at', { ascending: false })
.limit(20),
)Methods mirror PostgREST: eq, neq, gt, gte, lt, lte, like, ilike, is, in, contains, containedBy, textSearch, or, not, plus modifiers order, limit, range, select.
Geo (PostGIS): distance* / spatial predicates route to a SQL RPC you install (e.g. ST_DWithin), passed via opts.geoRpc. See the migration plan §5 / §8.7 for the geography(Point,4326) column + GiST index + RPC pattern.
Reads subscribe automatically (subscribe: true). The shared subscribeToTable helper updates the row cache and invalidates list keys on postgres_changes. Opt out per hook with { subscribe: false }.
Three manual setup steps are required per table (they can't be done from the client):
- Add the table to the publication:
alter publication supabase_realtime add table public.todos; - RLS is enforced on realtime — a client only receives changes to rows it can
SELECT. UPDATE/DELETEpayloads includeoldcolumns only withalter table public.todos replica identity full;
Supabase enforces access with Row Level Security policies on the table, not per-row permission arguments on the client. The hooks take no permissions argument — the database decides who can read and write each row.
Enable RLS on every table and write policies:
alter table public.todos enable row level security;
create policy "select own" on public.todos for select using (auth.uid() = user_id);
create policy "insert own" on public.todos for insert with check (auth.uid() = user_id);
create policy "update own" on public.todos for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "delete own" on public.todos for delete using (auth.uid() = user_id);Without RLS enabled, the anon/authenticated roles can read and write everything.
The offline engine follows one control flow: persist → pause → replay → resolve conflict.
- Persisted query cache — successful queries are dehydrated to storage and rehydrated on launch.
- Paused-mutation queue — mutations made offline are queued and replayed automatically on reconnect.
- Three-way conflict resolution —
last-write-wins(default),server-wins,merge-shallow, or a custom function.
Use createOfflineClient instead of createSupabaseClient to get a pre-wired QueryClient + persister:
import { createOfflineClient, webNetworkAdapter } from '@zeroin.earth/supabase-query'
const client = createOfflineClient({
url, anonKey,
networkAdapter: webNetworkAdapter, // reactNativeNetworkAdapter on RN
storage: window.localStorage, // batteries-included persister
conflictStrategy: 'last-write-wins',
})
// <SupabaseProvider client={client} queryClient={client.queryClient} persister={client.persister}>Only data mutations queue offline. These are online-only and intentionally out of the replay registry (they mint tokens / run RPCs / call Edge Functions server-side):
- all auth mutations (login, signup,
updateUser, MFA…) — GoTrue is inherently online;- teams:
useCreateTeamand every membership op (RPC / Edge Function). Only the plain-table team writes —useUpdateTeamName,useUpdateTeamPrefs,useDeleteTeam— queue offline;- push:
useRegisterDevice/useUnregisterDevice(need a live session) anduseSendPush.Don't expect offline login or offline device registration.
GoTrue-backed hooks over supabase.auth. Access = RLS, so there's no per-row permission API here.
const { mutate: login } = useLogin()
login({ email, password })
const { user } = useUser() // reactive to onAuthStateChange
const { mutate: logout } = useLogout()| Hook | Backend call |
|---|---|
useUser / useSuspenseUser |
auth.getUser() |
useSession |
auth.getSession() |
useSignUp |
auth.signUp({ email, password, options.data }) |
useLogin |
auth.signInWithPassword |
useLogout |
auth.signOut |
useOAuthLogin |
auth.signInWithOAuth({ provider }) (lowercase str) |
useMagicLink |
auth.signInWithOtp |
useEmailOtp / usePhoneOtp |
signInWithOtp → verifyOtp |
useAnonymousLogin |
auth.signInAnonymously (enable in dashboard) |
useUpdateUser |
auth.updateUser (name/prefs → user_metadata) |
usePasswordRecovery / useResetPassword |
resetPasswordForEmail → updateUser({ password }) |
useVerification |
verifyOtp / resend |
useMfa |
auth.mfa.* |
useIdentities |
auth.getUserIdentities / link / unlink |
No client API to list all sessions (
auth.adminis server-only) — there is nouseListSessions. Configure providers, redirect URLs, email templates, and MFA in Studio → Authentication.
Hooks over supabase.storage.from(bucket). Buckets are created by you (Studio → Storage), not at runtime.
| Hook | Call |
|---|---|
useFiles |
.list() |
useFile |
list/metadata |
useCreateFile |
.upload() — { bucket, path, file, options } |
useUpdateFile |
.update() |
useDeleteFile |
.remove() |
useFileDownload |
.download() |
useFileView / useFilePreview |
.getPublicUrl() (public buckets) |
useSignedUrl |
.createSignedUrl() (private buckets) |
For private buckets, add storage.objects RLS policies.
// Invoke an Edge Function
const { mutate: run } = useFunction()
run({ name: 'my-func', body: { /* … */ } })
// Read via RPC (query)
const { data } = useRpc('places_within', { p_lat, p_lng, p_meters: 500 })
// Mutate via RPC
const { mutate } = useCallRpc('increment_column')
mutate({ p_table: 'todos', p_id: id, p_column: 'views', p_amount: 1 })RPC covers two things PostgREST can't express as chained filters:
- Increment/decrement — PostgREST can't do
col = col + 1; install anincrement_columnRPC (plan §8.7). - Staged transactions — model atomic multi-step work as a bespoke Postgres function invoked via
.rpc().
Listing function executions has no client API — there is no
useListExecutions/useGetExecution.
Supabase has no teams primitive, so the library owns the schema and ships it as a versioned SQL artifact (sql/teams/0001_init.sql) — the same category as auth/ and storage/. Consumers adopt the schema; they don't design it. That's what makes teams transportable across projects.
- Roles are consumer-defined (
roles text[]); the library reserves exactly one structural role:'owner'. Pass a union for autocomplete:makeTeamsHooks<'owner' | 'editor' | 'viewer'>(). - Status is fixed:
pending | active | inactive | blocked. - Invites are by email, via the
team-inviteEdge Function (provisioning a user + sending mail needs the service role).
const { teams } = useTeams()
const { mutate: createTeam } = useCreateTeam()
createTeam({ name: 'Engineering', prefs: { color: 'blue' } })
const { mutate: invite } = useCreateMembership()
invite({ teamId, email: 'alice@example.com', roles: ['editor'] })Read hooks: useTeams, useTeam, useTeamPrefs, useTeamMemberships, useTeamMembership. Writes: useCreateTeam (RPC), useUpdateTeamName/useUpdateTeamPrefs/useDeleteTeam (plain table — offline-queueable), useCreateMembership (Edge Function), useUpdateMembership/useUpdateMembershipStatus/useDeleteMembership (RPC).
Install: see below — this module ships an Edge Function, so it's a 3-part install.
Two halves: client-side token registration (in this library) and a server-side sender (the send-push Edge Function, which holds the provider secrets). It branches by platform:
- Native (iOS/Android) → Expo Push API (fans out to APNs + FCM — no certs needed).
- Web → FCM HTTP v1 with a service-account JWT.
// after acquiring a token from expo-notifications / Firebase getToken():
const { mutate: register } = useRegisterDevice()
register({ token, platform: 'ios', provider: 'expo' })
const { mutate: send } = useSendPush()
send({ userIds: [uid], title: 'Hi', body: 'You have a new message' })useDeviceTokens lists the current user's tokens (RLS-scoped). Call useUnregisterDevice on logout. device_tokens is a fixed shape the library owns (sql/push/0001_init.sql).
App-side token acquisition is the consumer's responsibility (not bundled):
- Native:
expo-notifications→ request permission →getExpoPushTokenAsync()→useRegisterDevice({ token, platform, provider: 'expo' }). - Web: register the Firebase messaging service worker →
getToken({ vapidKey })→useRegisterDevice({ token, platform: 'web', provider: 'fcm' }).
Install: below — also a 3-part install.
Both teams and push ship an Edge Function. The installer only stamps the SQL — you must also deploy the function and set its secrets, or the module ships with a dead function. Three steps per module:
npx @zeroin.earth/supabase-query add teams # → supabase/migrations/<ts>_teams.sql
npx @zeroin.earth/supabase-query add push # → supabase/migrations/<ts>_push.sql
supabase db reset # locally (or `supabase db push` to the cloud)
supabase gen types typescript --local > src/database.types.tsThe installer is idempotent via a sq-<module>:N version marker (pass --force to restamp). Running add with no known module lists what's available.
The functions ship in the package under supabase/functions/. Copy the one you need into your own project and deploy it:
# teams:
supabase functions deploy team-invite
# push:
supabase functions deploy send-push# teams — reuses your project's auth mail config (§8.9); no extra secret.
# push:
# jq -c validates the JSON and stores it on one line — plain `cat` can leave
# newlines/escaping that break JSON.parse at runtime.
supabase secrets set FCM_SERVICE_ACCOUNT="$(jq -c . < service-account.json)"
supabase secrets set EXPO_ACCESS_TOKEN="<optional>"
# For server-to-server callers (cron, n8n, Home Assistant), set a dedicated
# secret rather than handing out the project's secret key:
supabase secrets set PUSH_SHARED_SECRET="$(openssl rand -hex 32)"send-push authorizes on any of: PUSH_SHARED_SECRET (preferred for
server-to-server), the project's secret key (SUPABASE_SECRET_KEYS, falling back
to the deprecated SUPABASE_SERVICE_ROLE_KEY), or a signed-in user's JWT.
For push you also generate an FCM VAPID key pair (web) and, optionally, an Expo access token. See migration plan §8.8 / §8.12 for the full walkthrough.
Import from the /react-native entry (native provider, NetInfo adapter, no web devtools):
import {
createSupabaseClient,
createSupabaseQuery,
reactNativeNetworkAdapter,
} from '@zeroin.earth/supabase-query/react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import 'react-native-url-polyfill/auto'
const client = createSupabaseClient<Database>({
url,
anonKey,
authStorage: AsyncStorage, // GoTrue persists the session natively
isNative: true, // disables URL-based session detection
})
authStorage(GoTrue session) and the offline persister storage are two different concerns — both useAsyncStorageon RN, but they solve different problems. PassAsyncStorageasstoragetocreateOfflineClientfor the query cache.
Everything is exported from the package root (and /react-native). Grouped by module:
- Client / provider:
createSupabaseClient,createSupabaseQuery,SupabaseProvider,useSupabase,SupabaseContext - TanStack wrappers:
useQuery,useMutation,useSuspenseQuery,useLazyQuery,useQueryClient - Keys / builder / realtime:
Keys,q,QueryBuilder,subscribeToTable - Data (
db/):useRow(s),useSuspenseRow(s),useInfiniteRows,useRowsWithPagination,useCreateRow,useUpdateRow,useUpsertRow,useDeleteRow,useIncrementColumn,useDecrementColumn,getRowQuery,getRowsQuery - Auth:
useUser,useSession,useSignUp,useLogin,useLogout,useOAuthLogin,useMagicLink,useEmailOtp,usePhoneOtp,useAnonymousLogin,useUpdateUser,usePasswordRecovery,useResetPassword,useVerification,useMfa,useIdentities - Storage:
useFiles,useFile,useCreateFile,useUpdateFile,useDeleteFile,useFileDownload,useFileView,useFilePreview,useSignedUrl - Functions / RPC:
useFunction,useSuspenseFunction,useRpc,useCallRpc - Teams:
useTeams,useTeam,useTeamPrefs,useTeamMemberships,useTeamMembership,useCreateTeam,useUpdateTeamName,useUpdateTeamPrefs,useDeleteTeam,useCreateMembership,useUpdateMembership,useUpdateMembershipStatus,useDeleteMembership,makeTeamsHooks - Push:
useDeviceTokens,useRegisterDevice,useUnregisterDevice,useSendPush,makePushHooks - Offline:
createOfflineClient,resolveConflict,conflictAwareUpdate,mutationRegistry,webNetworkAdapter(web),reactNativeNetworkAdapter(RN)
Full type exports (variables, results, entities) accompany each module.
bun install
supabase start # boots the local stack (DB/Auth/Storage/Realtime/Studio/Inbucket)
bun run gen:types # snapshot the fixture Database type for tests
bun test # bun test + happy-dom against the local stack
bun run lint # ESLint (flat config) + Prettier
bun run typecheck
bun run build # tsdown → dist/ (web) + react-native/The integration tests require a running supabase start stack.
MIT © Matt Suhay