npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zeroin.earth/supabase-query

v1.2.0

Published

React hooks library for Supabase with offline-first support, powered by @tanstack/react-query

Readme

@zeroin.earth/supabase-query

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.


Contents


Install

bun add @zeroin.earth/supabase-query @supabase/supabase-js @tanstack/react-query
# or: npm i / pnpm add / yarn add

Peer 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).


Quick start

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>
    </>
  )
}

The factory: createSupabaseQuery<Database>()

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 you Todo, 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.


Data hooks (db/)

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 error

What "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) ?? []

Filtering with the QueryBuilder

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.


Realtime

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):

  1. Add the table to the publication: alter publication supabase_realtime add table public.todos;
  2. RLS is enforced on realtime — a client only receives changes to rows it can SELECT.
  3. UPDATE/DELETE payloads include old columns only with alter table public.todos replica identity full;

Access control is RLS, not permissions

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.


Offline-first

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 resolutionlast-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: useCreateTeam and every membership op (RPC / Edge Function). Only the plain-table team writes — useUpdateTeamName, useUpdateTeamPrefs, useDeleteTeam — queue offline;
  • push: useRegisterDevice / useUnregisterDevice (need a live session) and useSendPush.

Don't expect offline login or offline device registration.


Auth

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 | signInWithOtpverifyOtp | | useAnonymousLogin | auth.signInAnonymously (enable in dashboard) | | useUpdateUser | auth.updateUser (name/prefs → user_metadata) | | usePasswordRecovery / useResetPassword | resetPasswordForEmailupdateUser({ password }) | | useVerification | verifyOtp / resend | | useMfa | auth.mfa.* | | useIdentities | auth.getUserIdentities / link / unlink |

No client API to list all sessions (auth.admin is server-only) — there is no useListSessions. Configure providers, redirect URLs, email templates, and MFA in Studio → Authentication.


Storage

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.


Edge Functions & RPC

// 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 an increment_column RPC (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.


Teams (library-owned module)

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-invite Edge 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: '[email protected]', 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.


Push notifications (library-owned module)

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.


Installing a library-owned module (SQL + function + secrets)

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:

1. Stamp the SQL

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.ts

The installer is idempotent via a sq-<module>:N version marker (pass --force to restamp). Running add with no known module lists what's available.

2. Deploy the Edge Function

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

3. Set the function's secrets

# 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.


React Native setup

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 use AsyncStorage on RN, but they solve different problems. Pass AsyncStorage as storage to createOfflineClient for the query cache.


API reference

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.


Contributing / local dev

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.

License

MIT © Matt Suhay