@motherbase/client is the official TypeScript SDK for Motherbase. Its public shape is intentionally close to supabase-js: createClient(...), client.from(table), client.auth, client.storage.from(bucket), client.channel(name), and client.functions.invoke().
Use Project Studio -> Connect for the exact API URL, WebSocket URL, package name, and project ID for a project.
Install
npm install @motherbase/client
# Optional, only when you use realtime:
npm install socket.io-clientInitialize
import { createClient, localStorageStorage } from '@motherbase/client'
export const client = createClient(
process.env.NEXT_PUBLIC_MOTHERBASE_URL!,
process.env.NEXT_PUBLIC_MOTHERBASE_ANON_KEY,
{ storage: localStorageStorage() },
)
await client.hydrate()The second argument is the project's publishable key, sent as an apikey
header. Find it on Project Studio → Connect. It is optional unless the project
has key enforcement turned on.
Passing a secret key instead (mb_secret_...) runs every request as
service_role, which bypasses row level security, and unlocks the
client.auth.admin namespace below. Server side only — see
API Keys. The SDK logs a console warning if a secret
key is used where window exists.
Client options — native shape:
createClient(url, key, {
storage, // session storage adapter, defaults to memoryStorage()
autoRefresh: true, // refresh once on 401, defaults to true
fetch, // custom fetch implementation
headers, // extra headers for every request
realtimeIoFactory, // test/custom socket.io factory
})The supabase-js option shape is accepted too, so a ported config compiles as-is:
createClient(url, key, {
auth: { persistSession: true, storage, storageKey, autoRefreshToken: true, detectSessionInUrl: true },
global: { headers, fetch },
db: { schema: 'public' },
})Storage precedence is storage → auth.storage → auth.persistSession
(true means localStorage, false means memory). auth.flowType and auth.debug
are accepted for signature parity and ignored — Motherbase runs the implicit flow
only. db.schema throws for anything but 'public', which is the only schema the
REST layer exposes.
Result shape
Expected API failures resolve to { data, error } instead of throwing.
const { data, error } = await client.from('products').select('*').limit(20)
if (error) {
console.error(error.message, error.status, error.code)
}error is null on success. Programmer errors can still throw, for example calling unsupported realtime channel event types.
Query builder
client.from(table) returns a chainable, awaitable PostgREST-style builder mapped to /rest/v1/:model.
const { data: products, error } = await client
.from('products')
.select('id,name,price')
.eq('status', 'active')
.in('id', ['p1', 'p2'])
.lt('price', 50)
.order('name', { ascending: false })
.range(0, 9)Single-row helpers:
const { data: product } = await client
.from('products')
.select('*')
.eq('id', 'p1')
.single()
const { data: maybeProduct } = await client
.from('products')
.select('*')
.eq('id', 'p1')
.maybeSingle()Mutations:
await client.from('products').insert({ name: 'Tea', price: 12 })
await client.from('products').update({ price: 9 }).eq('id', 'p1')
await client.from('products').delete().eq('id', 'p1')
// INSERT .. ON CONFLICT DO UPDATE. The target defaults to the primary key.
await client.from('products').upsert({ id: 'p1', name: 'Tea' })
await client.from('products').upsert({ sku: 'T1', name: 'Tea' }, { onConflict: 'sku' })
await client.from('products').upsert(rows, { ignoreDuplicates: true })Writes return no rows by default. Chain .select() when you need the inserted or
updated representation; the caller must also have SELECT access to receive it. Raw
REST writes default to Prefer: return=minimal, while Prefer: return=representation
requests rows explicitly. A bulk insert/upsert sends an explicit column set, so a
key missing from one row is still written as NULL (or as the column default under
{ defaultToNull: false }) rather than being inferred from the first row alone.
Two server-side rules apply to every query and will bite if you assume supabase-js
behaviour: an unbounded select() is capped at 1000 rows by default (the operator can
lower api.rest.maxRows; paginate with .range() or pass
an explicit .limit()), and an unfiltered delete() or update() is refused with a 400 for
non-admin callers — deliberately, since supabase-js would have rewritten every visible row.
Filters
| Group | Methods |
|---|---|
| Comparison | eq, neq, gt, gte, lt, lte |
| Pattern | like, ilike, likeAllOf, likeAnyOf, ilikeAllOf, ilikeAnyOf |
| Set / null | in, is, match |
| Logic | or, not, filter |
| Full-text | textSearch(column, query, { type: 'plain' | 'phrase' | 'websearch', config }) |
| Array / range containment | contains, containedBy, overlaps |
| Range position | rangeGt, rangeGte, rangeLt, rangeLte, rangeAdjacent |
await client
.from('posts')
.select('*')
.or('status.eq.live,and(price.gt.10,price.lt.20)')
.textSearch('body', 'cat & dog', { type: 'websearch', config: 'english' })
.contains('tags', ['news'])
.not('archived', 'is', null)Modifiers
| Modifier | Effect |
|---|---|
select, order, limit, range | The usual shaping. order, limit and range accept { referencedTable } so they apply to an embedded relation rather than the parent |
single(), maybeSingle() | One row instead of an array |
returns<T>() / overrideTypes<T>() | Compile-time cast only |
abortSignal(signal) | Cancel in flight |
throwOnError() | Reject instead of resolving { data: null, error } |
maxAffected(n) | Refuse a write that would touch more than n rows (Prefer: handling=strict, max-affected=n) |
rollback() | Run the query, then roll the transaction back (Prefer: tx=rollback) |
csv() | text/csv; data is a string |
geojson() | application/geo+json; data is the parsed object |
explain(options) | The query plan. Admin session only |
// Row count without fetching rows
const { count } = await client.from('products').select('*', { count: 'exact', head: true })
// CSV instead of JSON — data is a string
const { data: csv } = await client.from('products').select('*').csv()
// Query plan. Admin session only; anyone else gets 403 PGRST103.
const { data: plan } = await client.from('products').select('*').explain({ analyze: true })
// Cancel in flight
const controller = new AbortController()
await client.from('products').select('*').abortSignal(controller.signal)
// Validate a write without persisting it
await client.from('products').update({ price: 9 }).eq('id', 'p1').rollback()
// Refuse to touch more than one row, in case the filter is wrong
await client.from('products').update({ price: 9 }).eq('id', 'p1').maxAffected(1)maxAffected sends a Prefer header; a server that does not implement the
preference ignores it. It is a safety net, not a guarantee — keep the filter right.
Embedding
Foreign keys are resolved in both directions, so the PostgREST embedding syntax works:
await client.from('posts').select('id,title,author(id,name)')
await client.from('posts').select('*,comments!inner(body)')An embedded relation passes the same exposure and RLS checks as a top-level one.
Database functions
const { data } = await client.rpc('search_products', { term: 'tea' })
// Read-only form: arguments become query parameters
await client.rpc('search_products', { term: 'tea' }, { get: true })
// Count only
const { count } = await client.rpc('search_products', { term: 'tea' }, { head: true, count: 'exact' })Transactions
const { data, error } = await client.transaction(tx => {
tx.from('orders').insert({ id, total })
tx.from('stock').update({ count: 4 }).eq('sku', sku)
})All or nothing: one Postgres transaction for the whole callback. supabase-js has no
equivalent — the usual workaround is a sequence of awaited writes inside a try, which is
not a rollback, because whatever already committed stays committed.
The callback awaits nothing. It describes the writes; they are sent together and applied
together. data is one result array per operation, in declaration order.
- Writes only (
insert,upsert,update,delete). A read throws before anything is sent. - An empty callback throws.
- 100 operations maximum, enforced server side.
- The whole batch runs as one caller, so two operations can never run as different users.
Wire format: POST /rest/v1/batch.
Typed clients
Pass the type generated by motherbase gen types typescript and the whole builder becomes
schema-aware — relation names are checked, rows and inserts are typed, and .rpc() reads
its argument and return types from Functions:
import type { Database } from './database.types'
const client = createClient<Database>(url, anonKey)
const { data } = await client.from('products').select('id,name') // data: { id: string; name: string }[]Without a Database type the client behaves exactly as it does untyped. See the
CLI reference.
REST queries are enforced by PostgreSQL RLS. A signed-out request runs as anon; a signed-in request runs as authenticated with auth.uid() and auth.jwt() claims.
Auth
const { data: signedUp } = await client.auth.signUp({
email: 'user@example.com',
password: 'secret123',
username: 'alice',
})
const { data: signedIn } = await client.auth.signInWithPassword({
email: 'user@example.com',
password: 'secret123',
})
const { data: userResult } = await client.auth.getUser()
const { data: sessionResult } = await client.auth.getSession()
await client.auth.refreshSession()
await client.auth.signOut()Email OTP and password reset:
await client.auth.signInWithOtp({ email: 'user@example.com' })
await client.auth.verifyOtp({ email: 'user@example.com', token: '123456', type: 'email' })
await client.auth.resetPasswordForEmail('user@example.com')
await client.auth.resetPassword({ email: 'user@example.com', otp: '123456', password: 'new-secret' })OAuth:
const { data } = await client.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://app.example.com/callback',
scopes: 'email profile',
skipBrowserRedirect: true, // otherwise the browser is sent to data.url immediately
},
})In a browser, signInWithOAuth navigates to the provider straight away unless
skipBrowserRedirect is set. On the server it just resolves with data.url.
Account management:
// Profile fields
await client.auth.updateUser({ data: { name: 'Ada' } })
// Password change needs the current one — Better Auth has no blind overwrite
await client.auth.updateUser({ password: 'new-secret', currentPassword: 'old-secret' })
// Adopt a token captured elsewhere (an OAuth redirect, an SSR handoff).
// Validated against the server before it is persisted.
await client.auth.setSession({ access_token })
// Self-service account deletion
await client.auth.deleteUser()Auth state listener:
const { data: { subscription } } = client.auth.onAuthStateChange((event, session) => {
// event: 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED'
// | 'USER_UPDATED' | 'MFA_CHALLENGE_VERIFIED'
})
subscription.unsubscribe()INITIAL_SESSION fires once, as soon as the stored session has been read and before any
other event — asynchronously, so unsubscribing synchronously suppresses it, exactly as in
supabase-js. It is what the standard bootstrap
(if (event === 'INITIAL_SESSION') setLoading(false)) depends on: a signed-out visitor
produces no other event, so without it the app renders its loading state forever.
The SDK attaches the Better Auth session token automatically. On a 401 it revalidates the session once against /api/auth/get-session; concurrent requests share a single revalidation. If the session is gone it clears local state and emits SIGNED_OUT. refreshSession() re-checks the current session (Better Auth bearer sessions have no refresh token).
Two-factor
The server has shipped TOTP with backup codes and trusted devices from the start; this is the namespace that reaches it.
// Enrolment returns the otpauth URI (render it as a QR code), the raw secret for
// users who type it by hand, and the backup codes. Shown once.
const { data } = await client.auth.mfa.enroll({ password })
await client.auth.mfa.verify({ code: '123456', trustDevice: true })
await client.auth.mfa.listFactors()
await client.auth.mfa.regenerateBackupCodes({ password })
await client.auth.mfa.unenroll({ password })
// Second factor over email or SMS instead of TOTP
await client.auth.mfa.challenge({ channel: 'email' })
await client.auth.mfa.verify({ code, type: 'otp' })
// Spend a recovery code
await client.auth.mfa.verify({ code, type: 'backup' })Three differences from supabase.auth.mfa, stated rather than papered over:
- Enrolment requires the user's password. Better Auth treats enabling two-factor as a credential change.
- There is one TOTP factor, not a factor list: Better Auth models two-factor as a property of the user.
challenge()is a local no-op for TOTP and sends nothing, because the code is already on the user's device. It exists so the challenge/verify sequence compiles unchanged.
Enterprise SSO
await client.auth.signInWithSSO({ domain: 'acme.com' })
await client.auth.signInWithSSO({ providerId: 'acme-okta' })
await client.auth.signInWithSSO({ email, options: { redirectTo } })Resolves the identity provider and, in a browser, redirects to it unless
options.skipBrowserRedirect is set. The project needs the SSO plugin enabled and
at least one provider registered (Studio → Authentication → Organizations, with
the project-wide Auth · SSO switch enabled).
Organizations, invitations and teams
Organizations are an additional Motherbase namespace, not part of the Supabase-shaped API:
const { data: organization, error } = await client.auth.organization.create({
name: 'Acme',
slug: 'acme',
})
await client.auth.organization.inviteMember({
organizationId: organization!.id,
email: 'person@acme.example',
role: 'member',
})
const { data: full } = await client.auth.organization.getFullOrganization({
organizationId: organization!.id,
})The namespace exposes:
| Capability | Methods |
|---|---|
| Organization lifecycle | create, update, delete, list, getFullOrganization, setActive, checkSlug, leave |
| Members and permissions | listMembers, getActiveMember, getActiveMemberRole, removeMember, updateMemberRole, hasPermission |
| Invitations | inviteMember, acceptInvitation, rejectInvitation, cancelInvitation, getInvitation, listInvitations, listUserInvitations |
| Teams | createTeam, updateTeam, removeTeam, listTeams, setActiveTeam, listUserTeams, listTeamMembers, addTeamMember, removeTeamMember |
organizationId is optional on methods that target an organization and defaults
to the active organization in the session. create activates the new
organization unless keepCurrentActiveOrganization is true. Teams require
auth.organization.teamsEnabled; the available roles are owner, admin and
member. auth.organization.maxMembers is 0 for unlimited seats and applies
to direct member adds, invitation acceptance and SSO auto-provisioning.
Invitations are email-based. The invitation email contains the invitation id;
the recipient can inspect it with getInvitation({ id }), then call
acceptInvitation({ invitationId }) or rejectInvitation({ invitationId }).
An organization admin can withdraw an outgoing invitation with
cancelInvitation({ invitationId }).
For authorization checks, use hasPermission({ permissions }) or handle the
structured error returned by a refused operation. Do not infer permissions from
the fact that a caller is an organization member.
Runtime SSO provider management
Organization SSO providers are tenant data. The project must have Auth · SSO
enabled, and an organization provider must include that organization's
organizationId:
const { data, error } = await client.auth.sso.register({
providerId: 'acme-okta',
issuer: 'https://acme.okta.com',
domain: 'acme.example',
organizationId: organization.id,
oidcConfig: {
clientId: '…',
clientSecret: '…',
discoveryEndpoint: 'https://acme.okta.com/.well-known/openid-configuration',
scopes: ['openid', 'email', 'profile'],
},
})Use samlConfig instead of oidcConfig for SAML. The returned redirectURI is
the callback URL to register at the IdP. listProviders() and getProvider()
mask or omit secrets; updateProvider() merges the supplied config fields.
Only an organization owner or admin may manage a provider scoped to that
organization. A provider without organizationId is project-wide.
User administration
Requires a secret key, so this belongs on a server. A publishable key gets a 401 from these routes.
const admin = createClient(API_URL, process.env.MOTHERBASE_SECRET_KEY!)
await admin.auth.admin.listUsers({ page: 1, perPage: 50 })
await admin.auth.admin.getUserById(id)
await admin.auth.admin.createUser({ email, password, role: 'USER' })
await admin.auth.admin.updateUserById(id, { role: 'ADMIN', password })
await admin.auth.admin.signOut(id) // ends every session they hold
await admin.auth.admin.deleteUser(id)
await admin.auth.admin.inviteUserByEmail(email)generateLink is available through client.auth.admin.generateLink(...) with a secret
project key. It returns a one-time code flow; recovery is code-based and does not return an
action_link. Email-change link types are refused.
Linked identities
const { data } = await client.auth.getUserIdentities() // data.identities
await client.auth.linkIdentity({ provider: 'github', options: { redirectTo } })
await client.auth.unlinkIdentity({ provider: 'github', id })Backed by Better Auth's account list, which is why a password account shows email
as an identity: it is a real linked credential, exactly as in Supabase.
linkIdentity only starts the flow — in a browser it redirects to the provider
unless options.skipBrowserRedirect is set.
Native id-token sign-in and claims
// Sign in with Apple / Google One Tap: hand over the provider's id token
await client.auth.signInWithIdToken({ provider: 'apple', token, nonce })
// Session claims as a JWT payload, plus the token itself
const { data } = await client.auth.getClaims() // { claims, token }
// Revalidate on a timer (opt-in; the SDK already refreshes on a 401)
client.auth.startAutoRefresh(60_000)
client.auth.stopAutoRefresh()getClaims() asks the server's JWT plugin to mint a token and decodes it. The
decode is not a verification — verify server side against /api/auth/jwks.
Not exposed
PKCE code exchange (exchangeCodeForSession) and admin generateLink are supported. The
compatibility matrix lists every remaining gap.
Session storage
For a ready-made wiring per framework — Next.js, React, Vue, Nuxt, SvelteKit, Expo, Node — see Framework Quickstarts.
Exports:
import {
memoryStorage,
localStorageStorage,
cookieStorage,
serverCookieStorage,
} from '@motherbase/client'Browser local storage:
const client = createClient(API_URL, ANON_KEY, {
storage: localStorageStorage(),
})Browser cookies:
const client = createClient(API_URL, ANON_KEY, {
storage: cookieStorage({ secure: true, sameSite: 'lax' }),
})Next.js server cookie storage. serverCookieStorage(cookies, key?, { secure? })
takes the batch getAll / setAll shape that next/headers, NextRequest.cookies
and NextResponse.cookies already expose, so the store can be passed through:
import { cookies } from 'next/headers'
import { createClient, serverCookieStorage } from '@motherbase/client'
export async function createServerClient() {
const store = await cookies()
return createClient(API_URL, ANON_KEY, {
storage: serverCookieStorage({
getAll: () => store.getAll(),
setAll: (list) => list.forEach(({ name, value, options }) => store.set(name, value, options)),
}),
})
}In a Next.js app, prefer createBrowserClient / createServerClient from
@motherbase/ssr, which wire this up for you.
Expo / React Native:
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@motherbase/client'
const client = createClient(API_URL, ANON_KEY, {
storage: {
async get() {
const raw = await AsyncStorage.getItem('session')
return raw ? JSON.parse(raw) : null
},
async set(session) {
if (session) await AsyncStorage.setItem('session', JSON.stringify(session))
else await AsyncStorage.removeItem('session')
},
},
})Storage
const bucket = client.storage.from('avatars')
const { data: uploaded, error } = await bucket.upload('me.png', file, {
visibility: 'public',
})
if (!error && uploaded) {
const { data: blob } = await bucket.download(uploaded.path)
const { data: signed } = await bucket.createSignedUrl(uploaded.path, 3600)
const { data: pub } = bucket.getPublicUrl(uploaded.path)
await bucket.remove([uploaded.path])
}Objects are addressed by path within the bucket, the same way supabase-js does.
upload resolves to { path, id, fullPath }; download, remove, createSignedUrl
and getPublicUrl all take that path. A bare UUID is also accepted anywhere a path is,
and routes to the object by its server-issued file id instead — useful when you stored the
id rather than the key.
Listing:
const { data: objects } = await bucket.list('avatars/', {
limit: 50,
offset: 0,
search: 'me',
sortBy: { column: 'name', order: 'asc' },
})Many files at once:
const { data: results } = await bucket.uploadMany(
files.map(file => ({ path: `avatars/${file.name}`, file })),
{
concurrency: 4,
onProgress: ({ completed, total, path, error }) => setProgress(completed / total),
},
)uploadMany never rejects: each entry comes back as { path, data, error }, in input order,
so one bad file does not lose the other nineteen results. concurrency defaults to 4, and the
workers share one cursor rather than fixed slices, so a slow file does not stall the rest.
storage.from(bucket).upload(...) sends the bucket slug to Motherbase. Quota, CORS, access tokens, and webhooks are still managed from Project Studio, the admin API, or the CLI; buckets themselves can also be administered from the SDK (below).
Bucket administration
const { data: buckets } = await client.storage.listBuckets()
const { data: bucket } = await client.storage.getBucket('avatars')
await client.storage.createBucket('avatars', { public: true, name: 'Avatars' })
await client.storage.updateBucket('avatars', { public: false })
await client.storage.emptyBucket('avatars') // objects go, bucket stays
await client.storage.deleteBucket('avatars', { force: true })Every one of these needs a secret project API key — the same rule Supabase
applies, where bucket administration is a service-role operation. With a
publishable key they resolve with a 401 error, so keep them in server code and
setup scripts, never in a browser bundle. deleteBucket refuses a bucket that
still holds objects unless force is passed, so one unqualified call cannot
destroy the files in it.
getPublicUrl(path) is synchronous and returns <url>/api/storage/<bucket>/files/<path>.
Use it only for public bucket objects — it signs nothing and checks nothing. Private
objects should be read with download() or createSignedUrl() from an authenticated
session.
Copy, move, replace, and metadata:
await bucket.update('me.png', file) // upload with upsert forced on
await bucket.copy('me.png', 'archive/me.png', { upsert: true })
await bucket.move('me.png', 'archive/me.png')
const { data: meta } = await bucket.info('me.png') // no download
const { data: there } = await bucket.exists('me.png')exists() reads a 404 as false. Anything else — a 403, a network failure —
comes back as an error rather than as "no", because treating an authorization
failure as absence is how a UI offers to create something that already exists.
Signed URLs:
const { data } = await bucket.createSignedUrl('me.png', 900) // 15 minutes
// data.signedUrl, data.expiresAt
// Several at once. Each entry reports on its own; one missing object does not
// fail the batch.
const { data: many } = await bucket.createSignedUrls(['a.png', 'b.png'], 900)
// [{ path, signedUrl, error }, …]expiresIn is in seconds, defaults to 3600, and must be between 1 and 604800 (7 days,
the AWS SigV4 ceiling). Anything outside that is a 400 rather than a silent clamp — a
caller asking for 30 days wants a 30-day URL, and quietly returning a 7-day one is the same
bug as ignoring the argument.
Direct-to-storage uploads (S3 driver only):
const { data: slot } = await bucket.createSignedUploadUrl('me.png', {
size: file.size,
contentType: file.type,
})
await bucket.uploadToSignedUrl('me.png', slot.token, file)Unlike supabase-js this needs the byte size up front: the upload is reserved
against the bucket quota and the signature is bound to that exact length, which is
what stops a signed URL from becoming an unbounded write. uploadToSignedUrl PUTs
the bytes and then finalises server-side; the object only becomes readable after
the finalise, so a PUT that lands with a failed finalise leaves a reservation to
expire rather than a half-visible object. The local driver has no presigned
uploads.
expiresAt is null when nothing actually expires: a public object's URL is permanent,
and a private object under the local storage driver resolves to the authenticated
streaming endpoint, which is bound to the session rather than to a clock and so cannot be
handed to a third party at all. Check expiresAt rather than assuming expiresIn applied.
Realtime
const channel = client
.channel('room-products')
.on('postgres_changes', { event: '*', schema: 'public', table: 'products' }, (payload) => {
console.log(payload.eventType, payload.new, payload.old)
})
.subscribe((status, error) => {
console.log(status, error)
})
client.getChannels() // every channel this client holds
await client.removeChannel(channel)
await client.removeAllChannels()Realtime uses Socket.IO at /ws. socket.io-client is imported lazily when connect() runs, so apps that never use realtime do not need the optional dependency.
Supported channel event types: postgres_changes, broadcast, presence. Any other type
throws immediately rather than silently never firing.
postgres_changes event filters: INSERT, UPDATE, DELETE, *.
Broadcast:
const room = client.channel('room-1', { config: { broadcast: { self: true, ack: true } } })
room.on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
room.subscribe()
await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 12, y: 40 } })The sender receives its own message only with config.broadcast.self. With
config.broadcast.ack, send() resolves on the server ack ('ok' | 'timed out' | 'error')
instead of resolving immediately.
Presence:
const room = client.channel('room-1', { config: { presence: { key: userId } } })
room.on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
room.on('presence', { event: 'join' }, ({ key, newPresences }) => {})
room.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {})
room.subscribe(status => {
if (status === 'SUBSCRIBED') void room.track({ name: 'Ada', typing: false })
})
await room.untrack()The server sends the full presence state; the client diffs it into sync / join / leave.
subscribe() returns the channel, not a promise — track() and send() before the join ack
are rejected server-side, so issue the first one from the SUBSCRIBED callback. See
Realtime for presence keys, typing indicators, and reconnection.
Channel status, reported to the subscribe() callback: SUBSCRIBED, TIMED_OUT,
CHANNEL_ERROR, CLOSED.
Payload shape:
interface PostgresChangePayload<T> {
schema: string
table: string
eventType: 'INSERT' | 'UPDATE' | 'DELETE'
new: T | Record<string, never>
old: T | Record<string, never>
commit_timestamp: string
}Realtime table events respect the same RLS visibility model as REST for rows that still exist. Delete events carry the deleted row in old.
The low-level client.realtime module is exported for direct table watchers and status listeners, but app code should prefer client.channel().
Edge functions
const { data, error } = await client.functions.invoke('hello', {
body: { name: 'Ada' },
method: 'POST', // GET and DELETE send no body
headers: { 'x-trace': '1' },
})invoke always resolves { data, error } — it never throws — but error is an
instance of the supabase-js error classes, so ported code that branches on them
keeps working:
import { FunctionsHttpError, FunctionsFetchError } from '@motherbase/client'
if (error instanceof FunctionsHttpError) console.error(error.status)FunctionsHttpError means the function ran and answered non-2xx;
FunctionsFetchError means the request never reached it. FunctionsRelayError and
the FunctionsError base are exported for parity.
Public invocation is disabled by default. Enable EDGE_PUBLIC_INVOKE_ENABLED=true, or
call protected routes from a trusted server with an edge token:
await client.functions.invoke('hello', {
body: {},
auth: { type: 'edge-token', token: process.env.EDGE_INVOKE_TOKEN! },
})Subpath exports
Two extra entry points ship in the same package. Neither is loaded by @motherbase/client itself.
@motherbase/client/query
Cache keys and fetchers for React Query or SWR. No dependency on either: the returned object
is a plain { queryKey, queryFn }, which is what useQuery takes, and SWR uses the same two
fields as key and fetcher.
import { keys, listQuery, rowQuery, allRowsQuery } from '@motherbase/client/query'
useQuery(listQuery(client, 'posts', {
filters: { author },
build: q => q.select('*').eq('author', author).order('created_at'),
}))
useQuery(rowQuery(client, 'posts', id)) // resolves null when absent, does not throw
useQuery(allRowsQuery(client, 'tags')) // walks stream(), ignores api.rest.maxRowsThe fetchers throw on { error } instead of returning it. That is the whole point: a fetcher
that resolves with { data: null, error } looks successful to React Query, so the error gets
cached, isError stays false, and the UI renders an empty list forever.
Keys are hierarchical — keys.table('posts') is a prefix of every list and row key below it, so
one invalidateQueries after a write reaches all of them. Pass filters for any filtered list, or
two different filters on the same table share one cache entry.
@motherbase/client/testing
Run a query as a specific user, or as nobody, so an RLS policy can actually be asserted.
import { createTestClient } from '@motherbase/client/testing'
const asAlice = createTestClient(url, secretKey, { as: alice.id })
const asBob = createTestClient(url, secretKey, { as: bob.id })
const asAnon = createTestClient(url, secretKey, { as: 'anon' })
expect((await asAlice.from('notes').select('*')).data).toHaveLength(1)
expect((await asBob.from('notes').select('*')).data).toHaveLength(0)as takes a user id, 'anon' for a signed-out visitor, or 'service_role' to keep the secret
key's own bypass-everything identity. Each client gets its own in-memory session, so two of them
in one file cannot clobber each other.
Server side only. It needs the project secret key, which bypasses RLS and must never reach a
browser. createTestClient throws if the key is not an mb_secret_… one, and the server refuses
the x-motherbase-impersonate header with a 403 for any other key — so the worst case is a failed
request, not a privilege escalation. Impersonation can only ever lower the privilege of a caller
who already had everything.
Compatibility matrix
Every row below is produced by a contract test that runs the real
@motherbase/client against a real Motherbase server backed by a real Postgres,
in the backend repository
(apps/server/tests/sdk-contract.integration.test.ts). The test writes
packages/client/COMPATIBILITY.md; npm run docs:sync splices that file in
here. The table cannot claim more or less than the code does, and a row marked
Not supported asserts the current failure behaviour — implementing the
feature breaks the test and forces the table to be regenerated.
- Supported: behaves as supabase-js does.
- Partial: works, with the difference stated in the note.
- Not supported: not implemented today. The note says what happens if you call it.
Client
| Surface | Status | Notes |
|---|---|---|
createClient(url, key, options) | Supported | The key is sent as an apikey header. Options cover session storage, a fetch override and extra headers. |
client.getChannels() | Supported | Returns the channels this client has created, as supabase-js does. |
createClient(url, key, { auth, db, global }) | Supported | supabase-js nests its options under auth, db, global and realtime, and ported code passes them that way. Both spellings are read, the flat one winning where they collide, so { auth: { persistSession } } and { global: { headers, fetch } } take effect instead of being silently dropped. auth.storage accepts either adapter shape, including a React Native AsyncStorage. |
client.rpc(fn, args) | Supported | POST and GET /rest/v1/rpc/:fn resolve a public function from the catalog and call it with named arguments inside the caller RLS transaction. SECURITY DEFINER functions are refused unless an operator allowlists them, and an unknown function is a 404. |
client.schema('other') / createClient(url, key, { db: { schema } }) | Partial | schema('public') is the client. schema(name).rpc() calls functions in another schema through the Content-Profile/Accept-Profile headers, for the schemas a feature exposes (Queues' pgmq_public); any other is refused with PGRST106. Tables are served from public only, so schema(name).from() and the db.schema constructor option throw with the reason rather than reading public under another name. |
createClient<Database>(url, key) | Supported | motherbase gen types typescript emits the supabase-js Database shape (Row, Insert, Update, Relationships, Enums, Functions) from the Postgres catalog, and createClient<Database> types from() and rpc() from it. Unknown columns are still rejected at runtime as well. |
Data · verbs
| Surface | Status | Notes |
|---|---|---|
from(t).delete().eq(col, v) | Supported | Deleted rows are returned when .select() is requested. The delete is bound to the row versions whose realtime audience was captured. |
from(t).delete() | Partial | An unfiltered delete is refused with 400 for non-admin callers, on purpose. supabase-js would delete every visible row. |
from(t).insert([rowA, rowB]) | Supported | An array body is compiled into one multi-row INSERT. Rows may declare different key subsets; a key a row omits is written as DEFAULT so the column default applies. |
from(t).insert(row) | Supported | One row per call; .select() returns it with server defaults applied. Without .select(), data is null. |
from(t).select('*') | Supported | Awaitable, resolves to { data, error } and never throws for an API error. |
from(t).update(values).eq(col, v) | Supported | Returns the updated rows when .select() is requested. A filter that matches nothing is a 404 rather than an empty array. |
from(t).upsert(rows, { onConflict }) | Supported | A real INSERT ... ON CONFLICT DO UPDATE. The conflict target is ?on_conflict= or the catalog primary key, and a row that actually conflicted emits an updated realtime event rather than a created one. |
from(t).insert(row).select() | Supported | Writes return no rows by default. Add .select() to opt into a representation, subject to the caller’s SELECT policy. |
Data · filters
| Surface | Status | Notes |
|---|---|---|
from(t).containedBy(col, v) / .overlaps(col, v) | Supported | Compiled to the contained-by and overlap operators, on array and range columns. |
from(t).contains(col, value) | Supported | Compiled to the containment operator. It applies to array, range and jsonb columns; used on a scalar column it is a 400 rather than a silently empty result. |
from(t).eq(col, v) | Supported | Compiled to a parameterised equality. |
from(t).filter(col, op, value) | Supported | The escape hatch reaches any operator in the dialect, including ones with no dedicated builder method. |
from(t).gt/gte/lt/lte(col, v) | Supported | All four compile to the matching SQL comparison. |
from(t).ilike(col, pattern) | Supported | Case-insensitive ILIKE with PostgREST anchoring, so %ETA means "ends with eta" rather than a substring match. |
from(t).in(col, [..]) | Supported | Compiled to an IN list. The client quotes any value that needs it, so a value containing a comma stays one value instead of splitting into two. |
from(t).is(col, null | true | false) | Supported | is null becomes IS NULL. Boolean forms become an equality. |
from(t).eq('col->>key', v) | Supported | A jsonb path operand is parsed and compiled. The text arrow compares as text, the object arrow compares as jsonb, and every path segment is bound as a parameter rather than interpolated. |
from(t).like(col, pattern) | Supported | Case-sensitive LIKE with PostgREST anchoring. * is translated to %, and an unanchored pattern is an exact match. |
from(t).match({ col: v }) | Supported | Expanded into one equality filter per key, all ANDed together. |
from(t).neq(col, v) | Supported | Compiled to !=, so NULL values are excluded exactly as in PostgREST. |
from(t).not(col, op, value) | Supported | Emitted as a not. prefix and honoured on every operator, including not.is.null. |
from(t).or('a.eq.1,b.eq.2') | Supported | Emitted as an or group and compiled recursively, so a nested and() inside it works too. |
from(t).rangeGt/rangeGte/rangeLt/rangeLte/rangeAdjacent | Supported | Emitted as the strictly-left, strictly-right, not-extending and adjacent operators. The compat fixture has no range column, so this pins the emitted encoding rather than a result set. |
from(t).textSearch(col, query) | Supported | Maps to the four text-search operators, with an optional configuration rendered in parentheses. |
Data · modifiers
| Surface | Status | Notes |
|---|---|---|
from(t).select().abortSignal(signal) | Supported | The signal is handed to fetch, so an aborted query settles as an error instead of resolving rows. |
from(t).select().csv() / .explain() | Partial | csv() returns RFC 4180 text. explain() is admin-only: the server answers 403 for a normal caller, so an application cannot rely on it. |
from(t).limit(n) | Supported | Sent as ?limit=n. The server caps it at the console-tunable api.rest.maxRows (1000 by default). |
from(t).limit(n, { foreignTable }) | Supported | Emitted as <relation>.limit and applied to the embedded subquery, so a parent keeps all its rows while its children are capped. |
from(t).update(v).maxAffected(n) / .rollback() | Supported | maxAffected rejects and rolls back writes above the limit; rollback performs a dry run. Both preserve transaction atomicity even when no rows are requested. |
from(t).select().maybeSingle() | Supported | Resolves the first row or null. Applied client side. |
from(t).order(col, { ascending }) | Supported | Multiple order() calls are concatenated into one ?order= list, as PostgREST does. |
from(t).order(col, { nullsFirst, referencedTable }) | Supported | nullsFirst emits the nullsfirst and nullslast qualifiers, and referencedTable scopes the ordering to an embedded relation. |
from(t).range(from, to) | Supported | Translated to limit and offset. The server also reads a Range request header and always answers with Content-Range and Range-Unit. |
from(t).select().returns<T>() | Supported | A compile-time cast helper. It changes no request, matching supabase-js. |
from(t).select().single() | Supported | Resolves one row, or a PGRST116 error when the result is not exactly one row. Applied client side. |
from(t).select().throwOnError() | Supported | Rejects instead of resolving { data: null, error }, so ported code using try/catch works. Without it the envelope is still the default, in both SDKs. |
Data · response
| Surface | Status | Notes |
|---|---|---|
from(t).select('*', { count: 'exact' }) | Supported | Prefer count=exact, planned or estimated is honoured, and the total comes back with a Content-Range header computed in the same transaction as the rows. |
from(t).select() with no limit | Supported | An unbounded select returns up to api.rest.maxRows (1000 by default), which is what PostgREST does with db-max-rows and what a Supabase app was built against. One number governs both the default and the ceiling, so lowering it is how you cap an unbounded read. |
from(t).select('*, author(*)') | Supported | Embedded resources compile to correlated LATERAL subqueries inside the same RLS transaction, so embedded rows are policy-filtered too. Aliases, !inner, FK hints, nested filter/order/limit and the count aggregate all work. |
{ data, error } result envelope | Supported | Expected API failures resolve rather than throw. error is { message, status, code }. |
from(t).select('alias:col, col::text') | Supported | Renamed columns and casts both compile. A cast target must be a bare lowercase type name with an optional array marker, so it cannot smuggle SQL. |
from(t).select('id,label') | Supported | Column lists are validated against the table and compiled into the SELECT list. |
Auth
| Surface | Status | Notes |
|---|---|---|
auth.admin.* | Partial | User administration, MFA factor administration (admin.mfa.listFactors / deleteFactor) and generateLink, only with a secret project API key. Narrower than the supabase service role, which also opens SQL and DDL. generateLink covers signup, invite, magiclink and recovery; recovery is code-based here, so it returns email_otp and no action_link, and email_change_* is refused. |
auth.startAutoRefresh() / stopAutoRefresh() | Partial | Present so a ported app calling them keeps working, and useful on a long-lived screen that should notice a revoked session. They are opt-in rather than the supabase-js focus-driven loop, because the client already refreshes on a 401. |
auth.deleteUser() | Supported | Deletes the current account with a fresh session or password confirmation, then clears the local session. |
auth.exchangeCodeForSession(code) / getSessionFromUrl(href?) | Supported | With flowType 'pkce' the S256 challenge rides on the redirect URL and the callback returns ?code=, exchanged once against the verifier this browser kept (automatically on load, as detectSessionInUrl does). The implicit flow remains the default: the token comes back in the fragment and getSessionFromUrl reads it and strips it from the URL. |
auth.getClaims() | Partial | A Better Auth session token is an opaque bearer rather than a JWT, so this asks the server's jwt plugin to mint one and returns its decoded payload plus the token itself. The decode is not a verification: verify server side against /api/auth/jwks. |
auth.getSession() | Partial | Reads the locally stored session with no network call, so it never revalidates. Use getUser() for that. |
auth.getUser() | Supported | Validates the bearer against the server and returns the current user. |
auth.getUserIdentities() / linkIdentity() / unlinkIdentity() | Supported | Backed by Better Auth's account linking and mapped to the supabase-js UserIdentity shape. A password account lists an identity for its credential provider, as in Supabase. linkIdentity starts a provider redirect exactly as signInWithOAuth does. |
auth.mfa.* | Partial | TOTP with backup codes and trusted devices. A factor is TOTP or nothing, so listFactors reports the single TOTP factor and there is no phone factor to enrol. Enrol and unenroll need the password, which supabase-js does not ask for. challenge() is a local no-op for TOTP since the code is already on the device. getAuthenticatorAssuranceLevel reads the aal the server stamps on a session once it passes a second factor; currentAuthenticationMethods is always empty. |
auth.onAuthStateChange(cb) | Supported | Fires INITIAL_SESSION once on subscribe, as supabase-js does, so an app that clears its loading flag on that event also clears it for a signed-out visitor. Then SIGNED_IN, TOKEN_REFRESHED, USER_UPDATED and SIGNED_OUT. Returns { data: { subscription } }. |
auth.refreshSession() | Partial | Better Auth bearer sessions have no refresh token, so this revalidates the existing token instead of rotating it. |
auth.resend({ type, email }) | Partial | Every supabase-js flow is routed: 'signup' and 'email_change' re-send the email verification code, 'recovery' the password-reset one, and 'sms' and 'phone_change' re-send an SMS code through the phone-number flow. A missing identifier is named rather than silently ignored. |
auth.resetPasswordForEmail(email) then auth.resetPassword({ email, otp, password }) | Partial | The reset is completed with a one time code rather than the redirect link flow supabase-js uses. |
session.access_token / user.user_metadata | Partial | Every session and user leaving the SDK carries the supabase-js spellings alongside the native ones: access_token, refresh_token (always null: Better Auth bearer sessions have none), token_type, and on the user aud, role, created_at, updated_at, email_confirmed_at, phone, identities and user_metadata holding the profile fields. app_metadata is {} rather than a guessed provider, and expires_at is absent because the backend does not date the session, so code that schedules a refresh off it must not assume one. |
auth.setSession({ access_token, refresh_token }) | Partial | The access token is adopted and validated against the server. refresh_token is accepted for signature parity and ignored. |
auth.signInWithIdToken({ provider, token }) | Partial | Routed to the social sign-in endpoint with an idToken, so Sign in with Apple and Google One Tap work from a native app. The provider must be configured on the backend; with none configured the call resolves with an error rather than a session. |
auth.signInWithOAuth({ provider, options }) | Supported | Returns the provider authorize URL and redirects in a browser unless skipBrowserRedirect is set. The provider must be configured on the backend, and the redirect back is completed with auth.getSessionFromUrl(). |
auth.signInWithOtp({ email | phone }) then auth.verifyOtp({ email | phone, token }) | Partial | Email one-time codes work, and { phone } routes to the SMS flow when the project has the phone-number plugin enabled with a sender configured. Magic-link sign-in is not exposed through these methods. |
auth.signInWithPassword({ email, password }) | Supported | Persists the session through the configured storage adapter and emits SIGNED_IN. |
auth.signInWithSSO({ domain }) | Partial | Resolves the identity provider URL by domain, email, providerId or organizationSlug, and redirects unless skipBrowserRedirect is set. The project needs the SSO plugin on and a provider registered; there are no SAML metadata helpers on the client. |
auth.signOut() | Supported | Revokes the server session and clears the local one even if the server call fails. |
auth.signUp({ email, password }) | Supported | Returns { user, session }. An optional username is accepted as the display name. |
auth.updateUser({ data, email, password, nonce }) | Partial | Profile fields update freely. Replacing a password takes currentPassword or the nonce from reauthenticate(); a first password (a converted guest, a social login) needs neither. email upgrades an anonymous user in place; a permanent user's email cannot be changed. |
auth.verifyOtp({ type: 'sms' | 'recovery' | 'email_change' }) | Partial | Email and SMS both work, and the channel follows the identifier: { phone } verifies an SMS code through the phone-number flow, { email } an email one. SMS needs the phone-number plugin enabled with a sender configured. 'recovery' is not routed (use resetPassword) and email changes are not supported. |
Storage
| Surface | Status | Notes |
|---|---|---|
storage.createBucket/getBucket/listBuckets/updateBucket/emptyBucket/deleteBucket | Partial | All six are on the client and every one needs a secret project API key, which is the same rule Supabase applies to bucket administration. With a publishable key they resolve with a 401 rather than a partial answer. deleteBucket refuses a bucket that still holds objects unless force is passed, so one unqualified call cannot destroy the files in it. |
storage.from(b).createSignedUrl(path, expiresIn) | Partial | expiresIn is read, in seconds, and validated from 1 second to 7 days (the AWS SigV4 ceiling); a longer lifetime is refused rather than quietly clamped. It resolves to { signedUrl, expiresAt }, and expiresAt is null when nothing actually expires: a public object URL is permanent, and a private object on the local driver resolves to the authenticated streaming endpoint, which is bound to the session rather than to a clock. |
storage.from(b).createSignedUrls(paths, expiresIn) | Supported | Signs each key and reports per entry, so a missing object yields one entry carrying an error instead of failing the batch. There is no batch endpoint behind it: it is one request per key. |
storage.from(b).download(path) | Supported | Resolves a Blob, addressed by the key the object was uploaded under. A file id is still accepted. |
storage.from(b).getPublicUrl(path) | Supported | Synchronous, returns { data: { publicUrl } } built from the client base URL. |
storage.from(b).info(path) / .exists(path) | Supported | info returns the object's metadata without its body, in the supabase-js shape. exists reports false only for a 404: an authorization failure is reported as an error, because treating one as absence is how a UI offers to create something that is already there. |
storage.from(b).list(prefix, options) | Supported | Lists objects under a prefix in the Supabase response shape (name, id, created_at, updated_at, metadata), with limit, offset, search and sortBy. Keys come back relative to the bucket. |
storage.from(b).move(from, to) / .copy(from, to) | Supported | Both work within one bucket. The bytes stream through the upload path, so bucket quota, the size guard and the mime allowlist apply to a copy exactly as to a fresh upload. move deletes the source only once the copy has landed. |
storage.from(b).remove([path]) | Supported | Addressed by the key the object was uploaded under, as in supabase-js, and reported per entry. A bare file id still resolves, exactly as it does for download. |
storage.from(b).createSignedUploadUrl(path) / .uploadToSignedUrl(path, token, file) | Partial | Both exist and land the object at the key you asked for, but createSignedUploadUrl needs the byte size and content type up front: the upload is reserved against the bucket quota and the signature is bound to that exact length, which is what stops a signed URL becoming an unbounded write. Requires the S3 driver; the local driver has no presigned uploads. |
storage.from(b).download(path, { transform }) | Supported | Also getPublicUrl and createSignedUrl with { transform }, served by /storage/v1/render/image with Supabase limits. Without format, WebP is negotiated from Accept. |
storage.from(b).update(path, file) | Supported | upload with upsert forced on, exactly as in supabase-js. |
storage.from(b).upload(path, file, options) | Supported | The key is the one you passed. avatars/{uid}/pic.png style paths round trip through upload, download, remove and the signed URL. The file id keeps working everywhere a path does. |
storage.from(b).upload(path, file, { upsert, contentType }) | Partial | upsert works: without it a second write to the same key is a 409, with it the object is replaced. contentType overrides what the file itself declares. supabase-js' cacheControl is not part of UploadOptions, so a ported call naming it is a type error rather than a silently dropped option: cache headers are decided by the driver and, for a transform, by the render endpoint. |
Realtime
| Surface | Status | Notes |
|---|---|---|
channel(n).on('broadcast', { event }, cb) and channel.send(...) | Supported | Routed by topic. config.broadcast.self and config.broadcast.ack are both honoured. |
channel(n, { config: { private: true } }) | Supported | The flag is sent in the join payload; the server then checks the RLS policies on realtime.messages (SELECT to receive, INSERT to send or track). |
channel(n).on('system', ...) | Not supported | Only broadcast, presence and postgres_changes are accepted. Anything else throws immediately. |
channel(n).on('postgres_changes', { event, schema, table, filter }, cb).subscribe() | Supported | INSERT, UPDATE, DELETE and * are delivered with the supabase payload shape. Server side the stream is RLS filtered. |
channel(n).on('presence', ...) with track/untrack/presenceState | Supported | The server sends full state and the client diffs it into sync, join and leave, keyed by config.presence.key. |
client.removeChannel(channel) / client.removeAllChannels() | Supported | Unsubscribes and drops the channel, resolving to "ok". |
channel.subscribe(status => {}) | Supported | Reports SUBSCRIBED, TIMED_OUT, CLOSED and CHANNEL_ERROR, as supabase-js does. |
Edge functions
| Surface | Status | Notes |
|---|---|---|
functions.invoke(name, { body }) | Supported | POSTs the JSON body to /api/edge/:name with the current session bearer attached. |
functions.invoke error handling | Partial | invoke resolves { data: null, error } rather than throwing, but error is a real FunctionsHttpError (or FunctionsFetchError when the request never reached the server), so an instanceof check ported from supabase-js still works. |
functions.invoke(name, { headers, method, region }) | Partial | method and headers are honoured: the invoke route answers every verb and forwards the request headers to the function, so a function reading req.method sees the one you asked for. region is accepted and ignored, because a self-hosted Motherbase runs its functions in one place. |
Beyond the contract test
These surfaces exist on the client but are not rows in the generated matrix above, either because they have no supabase-js equivalent to be compatible with or because they are covered by their own tests rather than the compat suite. Stated here rather than left out, so the two tables together are the whole client.
| Surface | Status | Notes |
|---|---|---|
client.transaction(tx => …) | Motherbase only | Several writes, one Postgres transaction, all or nothing. No supabase-js equivalent |
bucket.uploadMany(files, { concurrency, onProgress }) | Motherbase only | Per-file { path, data, error }, never rejects |
@motherbase/client/query | Motherbase only | React Query / SWR keys and fetchers; the fetchers throw on error so a failure is not cached as data |
@motherbase/client/testing | Motherbase only | createTestClient(url, secretKey, { as }) to assert RLS policies. Secret key only, server side only |
client.auth.passkey.* | Supported | register, list, update, delete; sign-in is client.auth.signInWithPasskey() and needs WebAuthn JSON support |
client.auth.organization.* | Supported | Organizations, members, invitations and teams; Better Auth-shaped plugin wrappers with typed responses |
client.auth.sso.* | Supported | Registering and listing identity providers at runtime, separate from client.auth.signInWithSSO() |
client.auth.reauthenticate() | Supported | Emails the signed-in user a one-time nonce for updateUser({ password, nonce }) |
| Magic link sign-in | Not in this SDK | Enabled server side as a Better Auth plugin; call it with the Better Auth client. See Auth |
session.refresh_token | Not applicable | Better Auth bearer sessions have no refresh token; it is always null and refreshSession() revalidates instead |