From plain supabase-js
Move an app that calls supabase.from() directly to typed repositories, one query at a time.
better-supabase wraps the supabase-js client you already have. Nothing about
your database, RLS policies, auth or storage changes, and old
supabase.from() calls keep working next to the new repositories, so you
can move one query at a time.
Set up
Install and generate
pnpm add better-supabase
pnpm add -D pg @supabase/postgrest-typegen@0.4.0
pnpm better-supabase init
pnpm better-supabase gengen writes database.types.ts with the same output as
supabase gen types, so code typed with createClient<Database>() keeps
compiling. Point your existing imports at the new file, or keep generating
the old one until you've moved over.
Wrap the client you already create
import { defineSupabase } from "better-supabase";
import { schema } from "./generated.ts";
export const betterSupabase = defineSupabase(schema);const supabase = createClient(url, publishableKey); // or createServerClient(...)
const db = betterSupabase.connect(supabase);betterSupabase.connect() takes any SupabaseClient, including the one from
@supabase/ssr. Connect per request with the user's client, as you do
today, so RLS still applies. db.$client is the same client, for anything
you haven't moved yet.
Move queries over
Replace supabase.from() calls as you touch them, using the table below.
Once the server side is on repositories, you can replace your hand-written
@supabase/ssr setup with an adapter (see below).
Queries
The config's casing decides column names. With casing: 'camel',
organization_id becomes organizationId in where, select and the rows
you get back. With casing: 'snake', names stay as they are in the database.
| supabase-js | better-supabase |
|---|---|
.from('customers').select('id, name') | db.customers.findMany({ select: ['id', 'name'] }) |
.select('*') | findMany() (every column) |
.select('id, organization(name)') | select: ['id'], include: { organization: { select: ['name'] } } |
.select('*, notes(count)') | include: { _count: { notes: true } } |
.eq('status', 'active') | where: { status: 'active' } |
.neq, .gt, .gte, .lt, .lte | where: { total: { gte: 100 } } |
.in('status', ['lead', 'active']) | where: { status: { in: ['lead', 'active'] } } |
.is('deleted_at', null) | where: { deletedAt: null } |
.not('email', 'is', null) | where: { email: { not: null } } |
.ilike('name', '%acme%') | where: { name: { contains: 'acme' } } (escapes % and _) |
.or('status.eq.lead,kvk.eq.1001') | where: { OR: [{ status: 'lead' }, { kvk: '1001' }] } |
.textSearch('document', 'acme') | where: { document: { search: 'acme' } } |
.select('*, notes!inner(*)').eq('notes.kind', 'call') | where: { notes: { some: { kind: 'call' } } } |
.order('name', { ascending: false }) | orderBy: { name: 'desc' } |
.range(20, 29) | limit: 10, offset: 20, or paginate |
.eq('id', id).single() | findById(id) (a not_found error when missing) |
.eq('slug', slug).maybeSingle() | findUnique({ where: { slug } }) (null when missing) |
.eq('status', 'active').maybeSingle() | findOnly({ where: { status: 'active' } }) (multiple_rows for several) |
.limit(1).maybeSingle() | findFirst({ where }) |
.select('*', { count: 'exact', head: true }) | count({ where }) |
.select('*', { count: 'exact' }).range(20, 29) | paginate({ offset: 20, limit: 10, count: 'exact' }) |
.insert(row).select().single() | create(row) |
.insert(rows).select() | createMany(rows) |
.update(patch).eq('id', id).select().single() | update(id, patch) |
.update(patch).eq('status', 'lead') | updateMany({ where: { status: 'lead' }, data: patch }) |
.update(patch).eq('id', id).eq('organization', organization).select() | update(id, patch, { where: { organization } }) |
.update(patch).in('id', ids).select() | updateMany({ where: { id: { in: ids } }, data: patch, returning: true }) |
.upsert(row, { onConflict: 'organization_id,kvk' }) | upsert(row, { onConflict: ['organizationId', 'kvk'] }) |
.delete().eq('id', id) | delete(id) |
supabase.rpc('archive_customer', args) | db.$rpc('archive_customer', args) |
Each call is still one PostgREST request, including relation filters and counts. Every column, relation and operator is typed, and enum columns only accept their allowed values. See Filtering and Writing for the full list.
Errors
supabase-js returns { data, error } with a PostgrestError. Repositories
return a Result with the same two fields plus ok, and a DbError whose
kind tells you what happened:
// Before
const { data, error } = await supabase
.from("tags")
.insert({ name })
.select()
.single();
if (error?.code === "23505")
return { field: "name", message: "Tag already exists" };
if (error) throw error;
// After
const result = await db.tags.create({ organizationId, name });
if (result.error?.kind === "conflict")
return { field: "name", message: "Tag already exists" };
if (!result.ok) return result;
const tag = result.data;Use .orThrow() where you used to write if (error) throw error. The
Results page lists every kind and its HTTP status.
Replace the SSR glue
If you wrote the @supabase/ssr cookie handling yourself, an adapter
replaces it. It verifies the token locally, refreshes it only in the proxy,
and gives every handler a db for the current user:
| You have | Use |
|---|---|
createServerClient in a Next.js middleware.ts or proxy.ts | bs.proxy(request) (Next.js) |
createServerClient in Server Components and route handlers | const { db } = await bs.context(), bs.route(...) |
createBrowserClient | createClient(betterSupabase) (Browser client) |
| A client per request in Hono, oRPC or an Edge Function | bs.middleware() or bs.handler() (Frameworks) |
Auth flows (supabase.auth.signInWithOtp() and friends), storage and
realtime stay on the supabase-js client: db.$client on the server,
bs.supabase in the browser.
What to expect
- Types come from the schema, not the query string. A typo in a column
name is a type error, and the row type follows
selectandinclude. Rerunbetter-supabase genafter every migration, and addgen --checkto CI. - Database errors don't throw. Code that relied on try and catch
around a query needs
.orThrow(). - No
declare moduleorDatabasegenerics. Theschemaobject carries the types, so there's nothing to pass tocreateClient. - PostgREST's limits still apply. There are no transactions across requests; see Limitations for what to use instead.
Last updated on