Browse documentation

@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-client

Initialize

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

GroupMethods
Comparisoneq, neq, gt, gte, lt, lte
Patternlike, ilike, likeAllOf, likeAnyOf, ilikeAllOf, ilikeAnyOf
Set / nullin, is, match
Logicor, not, filter
Full-texttextSearch(column, query, { type: 'plain' | 'phrase' | 'websearch', config })
Array / range containmentcontains, containedBy, overlaps
Range positionrangeGt, 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

ModifierEffect
select, order, limit, rangeThe 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.

Note

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:

CapabilityMethods
Organization lifecyclecreate, update, delete, list, getFullOrganization, setActive, checkSlug, leave
Members and permissionslistMembers, getActiveMember, getActiveMemberRole, removeMember, updateMemberRole, hasPermission
InvitationsinviteMember, acceptInvitation, rejectInvitation, cancelInvitation, getInvitation, listInvitations, listUserInvitations
TeamscreateTeam, 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.

Note

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

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

Warning

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

SurfaceStatusNotes
createClient(url, key, options)SupportedThe key is sent as an apikey header. Options cover session storage, a fetch override and extra headers.
client.getChannels()SupportedReturns the channels this client has created, as supabase-js does.
createClient(url, key, { auth, db, global })Supportedsupabase-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)SupportedPOST 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 } })Partialschema('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)Supportedmotherbase 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

SurfaceStatusNotes
from(t).delete().eq(col, v)SupportedDeleted rows are returned when .select() is requested. The delete is bound to the row versions whose realtime audience was captured.
from(t).delete()PartialAn unfiltered delete is refused with 400 for non-admin callers, on purpose. supabase-js would delete every visible row.
from(t).insert([rowA, rowB])SupportedAn 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)SupportedOne row per call; .select() returns it with server defaults applied. Without .select(), data is null.
from(t).select('*')SupportedAwaitable, resolves to { data, error } and never throws for an API error.
from(t).update(values).eq(col, v)SupportedReturns 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 })SupportedA 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()SupportedWrites return no rows by default. Add .select() to opt into a representation, subject to the caller’s SELECT policy.

Data · filters

SurfaceStatusNotes
from(t).containedBy(col, v) / .overlaps(col, v)SupportedCompiled to the contained-by and overlap operators, on array and range columns.
from(t).contains(col, value)SupportedCompiled 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)SupportedCompiled to a parameterised equality.
from(t).filter(col, op, value)SupportedThe escape hatch reaches any operator in the dialect, including ones with no dedicated builder method.
from(t).gt/gte/lt/lte(col, v)SupportedAll four compile to the matching SQL comparison.
from(t).ilike(col, pattern)SupportedCase-insensitive ILIKE with PostgREST anchoring, so %ETA means "ends with eta" rather than a substring match.
from(t).in(col, [..])SupportedCompiled 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)Supportedis null becomes IS NULL. Boolean forms become an equality.
from(t).eq('col->>key', v)SupportedA 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)SupportedCase-sensitive LIKE with PostgREST anchoring. * is translated to %, and an unanchored pattern is an exact match.
from(t).match({ col: v })SupportedExpanded into one equality filter per key, all ANDed together.
from(t).neq(col, v)SupportedCompiled to !=, so NULL values are excluded exactly as in PostgREST.
from(t).not(col, op, value)SupportedEmitted as a not. prefix and honoured on every operator, including not.is.null.
from(t).or('a.eq.1,b.eq.2')SupportedEmitted as an or group and compiled recursively, so a nested and() inside it works too.
from(t).rangeGt/rangeGte/rangeLt/rangeLte/rangeAdjacentSupportedEmitted 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)SupportedMaps to the four text-search operators, with an optional configuration rendered in parentheses.

Data · modifiers

SurfaceStatusNotes
from(t).select().abortSignal(signal)SupportedThe signal is handed to fetch, so an aborted query settles as an error instead of resolving rows.
from(t).select().csv() / .explain()Partialcsv() 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)SupportedSent as ?limit=n. The server caps it at the console-tunable api.rest.maxRows (1000 by default).
from(t).limit(n, { foreignTable })SupportedEmitted 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()SupportedmaxAffected 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()SupportedResolves the first row or null. Applied client side.
from(t).order(col, { ascending })SupportedMultiple order() calls are concatenated into one ?order= list, as PostgREST does.
from(t).order(col, { nullsFirst, referencedTable })SupportednullsFirst emits the nullsfirst and nullslast qualifiers, and referencedTable scopes the ordering to an embedded relation.
from(t).range(from, to)SupportedTranslated 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>()SupportedA compile-time cast helper. It changes no request, matching supabase-js.
from(t).select().single()SupportedResolves one row, or a PGRST116 error when the result is not exactly one row. Applied client side.
from(t).select().throwOnError()SupportedRejects 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

SurfaceStatusNotes
from(t).select('*', { count: 'exact' })SupportedPrefer 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 limitSupportedAn 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(*)')SupportedEmbedded 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 envelopeSupportedExpected API failures resolve rather than throw. error is { message, status, code }.
from(t).select('alias:col, col::text')SupportedRenamed 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')SupportedColumn lists are validated against the table and compiled into the SELECT list.

Auth

SurfaceStatusNotes
auth.admin.*PartialUser 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()PartialPresent 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()SupportedDeletes the current account with a fresh session or password confirmation, then clears the local session.
auth.exchangeCodeForSession(code) / getSessionFromUrl(href?)SupportedWith 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()PartialA 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()PartialReads the locally stored session with no network call, so it never revalidates. Use getUser() for that.
auth.getUser()SupportedValidates the bearer against the server and returns the current user.
auth.getUserIdentities() / linkIdentity() / unlinkIdentity()SupportedBacked 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.*PartialTOTP 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)SupportedFires 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()PartialBetter Auth bearer sessions have no refresh token, so this revalidates the existing token instead of rotating it.
auth.resend({ type, email })PartialEvery 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 })PartialThe reset is completed with a one time code rather than the redirect link flow supabase-js uses.
session.access_token / user.user_metadataPartialEvery 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 })PartialThe access token is adopted and validated against the server. refresh_token is accepted for signature parity and ignored.
auth.signInWithIdToken({ provider, token })PartialRouted 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 })SupportedReturns 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 })PartialEmail 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 })SupportedPersists the session through the configured storage adapter and emits SIGNED_IN.
auth.signInWithSSO({ domain })PartialResolves 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()SupportedRevokes the server session and clears the local one even if the server call fails.
auth.signUp({ email, password })SupportedReturns { user, session }. An optional username is accepted as the display name.
auth.updateUser({ data, email, password, nonce })PartialProfile 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' })PartialEmail 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

SurfaceStatusNotes
storage.createBucket/getBucket/listBuckets/updateBucket/emptyBucket/deleteBucketPartialAll 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)PartialexpiresIn 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)SupportedSigns 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)SupportedResolves a Blob, addressed by the key the object was uploaded under. A file id is still accepted.
storage.from(b).getPublicUrl(path)SupportedSynchronous, returns { data: { publicUrl } } built from the client base URL.
storage.from(b).info(path) / .exists(path)Supportedinfo 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)SupportedLists 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)SupportedBoth 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])SupportedAddressed 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)PartialBoth 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 })SupportedAlso 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)Supportedupload with upsert forced on, exactly as in supabase-js.
storage.from(b).upload(path, file, options)SupportedThe 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 })Partialupsert 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

SurfaceStatusNotes
channel(n).on('broadcast', { event }, cb) and channel.send(...)SupportedRouted by topic. config.broadcast.self and config.broadcast.ack are both honoured.
channel(n, { config: { private: true } })SupportedThe 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 supportedOnly broadcast, presence and postgres_changes are accepted. Anything else throws immediately.
channel(n).on('postgres_changes', { event, schema, table, filter }, cb).subscribe()SupportedINSERT, UPDATE, DELETE and * are delivered with the supabase payload shape. Server side the stream is RLS filtered.
channel(n).on('presence', ...) with track/untrack/presenceStateSupportedThe server sends full state and the client diffs it into sync, join and leave, keyed by config.presence.key.
client.removeChannel(channel) / client.removeAllChannels()SupportedUnsubscribes and drops the channel, resolving to "ok".
channel.subscribe(status => {})SupportedReports SUBSCRIBED, TIMED_OUT, CLOSED and CHANNEL_ERROR, as supabase-js does.

Edge functions

SurfaceStatusNotes
functions.invoke(name, { body })SupportedPOSTs the JSON body to /api/edge/:name with the current session bearer attached.
functions.invoke error handlingPartialinvoke 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 })Partialmethod 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.

SurfaceStatusNotes
client.transaction(tx => …)Motherbase onlySeveral writes, one Postgres transaction, all or nothing. No supabase-js equivalent
bucket.uploadMany(files, { concurrency, onProgress })Motherbase onlyPer-file { path, data, error }, never rejects
@motherbase/client/queryMotherbase onlyReact Query / SWR keys and fetchers; the fetchers throw on error so a failure is not cached as data
@motherbase/client/testingMotherbase onlycreateTestClient(url, secretKey, { as }) to assert RLS policies. Secret key only, server side only
client.auth.passkey.*Supportedregister, list, update, delete; sign-in is client.auth.signInWithPasskey() and needs WebAuthn JSON support
client.auth.organization.*SupportedOrganizations, members, invitations and teams; Better Auth-shaped plugin wrappers with typed responses
client.auth.sso.*SupportedRegistering and listing identity providers at runtime, separate from client.auth.signInWithSSO()
client.auth.reauthenticate()SupportedEmails the signed-in user a one-time nonce for updateUser({ password, nonce })
Magic link sign-inNot in this SDKEnabled server side as a Better Auth plugin; call it with the Better Auth client. See Auth
session.refresh_tokenNot applicableBetter Auth bearer sessions have no refresh token; it is always null and refreshSession() revalidates instead