@motherbase/client
v1.3.1
Published
Typed client SDK for Motherbase, the open source self-hosted backend platform.
Maintainers
Readme
@motherbase/client
A supabase-js-shaped client SDK for the Motherbase self-hosted backend. It
keeps the same entry points (createClient, .from(), .auth, .storage,
.channel(), .functions) and the same { data, error } result convention, so
supabase-js code reads and reviews the same way here.
Scope, honestly. For the data layer, this is a package swap: change the
import and the client factory, keep your queries. Every supabase-js data call
in the contract suite is either supported or supported with a stated difference,
including relational embedding (select('*, author(*)')), .rpc(), native
upsert, count, csv(), the full filter set and PostgREST error codes.
Auth and realtime are close, with named gaps (no PKCE code exchange, no mfa
namespace on the client, no private channels).
Storage is path-addressed too: upload('avatars/123/pic.png') then
download('avatars/123/pic.png') round trips, list(prefix) works, and keys are
traversal-checked. Still missing there: move, copy, batch signed URLs and
image transforms, and authorization is ownership plus bucket tokens rather than
RLS policies on the objects table. See
docs/migrating-from-supabase.md.
Do not plan a migration from the paragraph above. Plan it from
COMPATIBILITY.md, which is generated from contract tests
and is exhaustive, per method, with the exact difference for every partial row.
Typed, isomorphic (browser, Next.js server & client, Expo / React Native), built
on global fetch with zero runtime dependencies (socket.io-client is an
optional peer dependency, used only for realtime).
Install
npm i @motherbase/client
# optional, only if you use realtime:
npm i socket.io-clientQuickstart
import { createClient, localStorageStorage } from '@motherbase/client';
const client = createClient(
'http://localhost:5001',
'your-anon-key', // optional; sent as an `apikey` header
{ storage: localStorageStorage() } // omit for in-memory (SSR-safe) sessions
);
await client.hydrate(); // restore a persisted session (optional)
// Auth
await client.auth.signUp({ email: '[email protected]', password: 'secret123' });
const { data, error } = await client.auth.signInWithPassword({
email: '[email protected]',
password: 'secret123',
});
// Query builder (awaitable, PostgREST-style)
const { data: products } = await client
.from('products')
.select('id,name,price')
.lt('price', 50)
.order('name', { ascending: false })
.limit(20);
// Storage. `uploaded.path` is the key you passed, so later download/remove/
// signed-URL calls take the same string.
const { data: uploaded } = await client.storage.from('avatars').upload(`${userId}/me.png`, myBlob, {
visibility: 'public',
});
// Realtime: postgres_changes, broadcast, presence over one channel (Supabase-shaped)
const room = client
.channel('room-1', { config: { broadcast: { self: true }, presence: { key: userId } } })
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages', filter: 'room_id=eq.42' }, p => {
console.log(p.eventType, p.new, p.old);
})
.on('broadcast', { event: 'cursor' }, ({ payload }) => console.log(payload))
.on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
.subscribe();
await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 1, y: 2 } });
await room.track({ online_at: Date.now() });
// Edge functions
const { data: result } = await client.functions.invoke('hello', { body: { name: 'Ada' } });Every data/auth/storage call resolves to { data, error } and never throws
for expected API errors. error is null on success, otherwise
{ message, status?, code? }.
The SDK attaches the access token automatically and revalidates it on a 401
(single-flight: concurrent requests share one revalidation). Sessions are Better
Auth bearer sessions, so there is no separate refresh token to rotate: when the
token is no longer valid the SDK clears the session and emits SIGNED_OUT.
API surface (cheatsheet)
// Data. Filters: eq neq gt gte lt lte like ilike likeAllOf likeAnyOf ilikeAllOf
// ilikeAnyOf in is not or filter match textSearch contains containedBy overlaps
// rangeGt rangeGte rangeLt rangeLte rangeAdjacent
client.from(table).select(cols).eq(col, v).in(col, [..]).order(col, { ascending, nullsFirst, referencedTable })
client.from(table).select(cols).limit(n, { referencedTable }).range(from, to).abortSignal(signal)
client.from(table).select(cols).or('a.eq.1,and(b.gt.2,c.is.null)') // nests, as PostgREST does
client.from(table).select(cols).not(col, 'eq', v).filter(col, 'isdistinct', v)
client.from(table).select(cols).textSearch(col, q, { type: 'websearch', config: 'english' })
client.from(table).select(cols).single() | .maybeSingle() | .returns<T>() | .csv() | .explain()
client.from(table).insert(row) | .insert([rowA, rowB], { defaultToNull: false })
client.from(table).upsert(rows, { onConflict, ignoreDuplicates })
client.from(table).update(values).eq(..) // a filter is required
client.from(table).delete().eq(..) // a filter is required
// Every call resolves { data, error, count }. `count` is null unless you ask:
// .select('*', { count: 'exact', head: true }).
// Values are quoted for you: .in('name', ['a,b']) is one value, not two.
// Writes always return their rows: no .select() needed, and no way to opt out.
// An unbounded select is capped at 100 rows server side: paginate with .range().
// explain() needs an admin caller; a normal caller gets 403.
// Relational embedding. Compiles to a correlated LATERAL subquery inside the
// same RLS transaction, so embedded rows are policy-filtered too.
client.from('posts').select('id, author(name), comments(id)') // object / array by FK shape
client.from('posts').select('id, author!inner(name)') // inner join semantics
client.from('posts').select('id, comments(count)') // aggregate
// Database functions (typed from the generated Database["public"]["Functions"]).
// Runs in the caller's RLS transaction. SECURITY DEFINER functions are refused
// unless an operator allowlists them.
client.rpc(fn, args, { get, head, count })
// Auth → { data, error }
client.auth.signUp({ email, password, username? })
client.auth.signInWithPassword({ email, password })
client.auth.signInWithOtp({ email }) // emails a code
client.auth.verifyOtp({ email, token }) // completes the OTP sign-in
client.auth.signInWithOAuth({ provider, options: { redirectTo, scopes, skipBrowserRedirect } })
client.auth.getSessionFromUrl() // finishes the OAuth redirect, see below
client.auth.resetPasswordForEmail(email)
client.auth.resetPassword({ email, otp, password })
client.auth.updateUser({ data, password, currentPassword })
client.auth.setSession({ access_token })
client.auth.signOut()
client.auth.getUser() // { data: { user } }, revalidates against the server
client.auth.getSession() // { data: { session } }, local only
client.auth.refreshSession()
client.auth.onAuthStateChange((event, session) => {}) // SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED
client.auth.deleteUser({ password }) // self-service, needs the backend flow enabled
// Storage → { data, error }. `path` is the object key inside the bucket, and it
// round trips. The file id returned by upload still works anywhere a path does.
client.storage.from(bucket).upload(path, file, { visibility, upsert })
client.storage.from(bucket).download(path)
client.storage.from(bucket).remove([path])
client.storage.from(bucket).list(prefix, { limit, offset, search, sortBy })
client.storage.from(bucket).createSignedUrl(path, expiresIn) // expiresIn is ignored
client.storage.from(bucket).getPublicUrl(path) // sync
// Realtime
client.channel(name, { config: { broadcast: { self, ack }, presence: { key } } })
.on('postgres_changes', { event, schema, table, filter }, cb)
.on('broadcast', { event }, cb)
.on('presence', { event: 'sync' | 'join' | 'leave' }, cb)
.subscribe(status => {}) // SUBSCRIBED | TIMED_OUT | CLOSED | CHANNEL_ERROR
channel.send({ type: 'broadcast', event, payload })
channel.track(state); channel.untrack(); channel.presenceState()
client.removeChannel(channel)
// Edge functions → { data, error }
client.functions.invoke(name, { body })Social sign-in (OAuth)
A provider callback is a browser redirect, so it cannot answer the SDK with a session the way a password sign-in does. Two calls, not one:
// 1. Wherever the button lives. Navigates the browser to the backend, which
// redirects on to the provider.
await client.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: 'https://app.example.com/auth/callback' },
});
// 2. On that page (or on app start). Reads the token the backend appended to
// the redirect, stores the session, and strips it from the address bar.
const { data, error } = await client.auth.getSessionFromUrl();getSessionFromUrl() resolves with a null session and no error when there is
nothing to read, so calling it on every page load is safe.
Step 1 is a full-page navigation, not a fetch. That is deliberate: the backend sets an OAuth state cookie there and re-checks it on the provider callback, and a cookie set on a cross-site fetch response is refused by Safari and Firefox and by Chrome in incognito. Navigating makes it a first-party cookie, so the flow works in every browser even when your app and your API are on different domains. If you are not using the SDK, point the button at the same URL:
https://<your-api>/api/auth/sign-in/social/google?callbackURL=https://app.example.com/auth/callbackcallbackURL is validated against the trusted origins, so it has to be an
origin you allowlisted.
Two backend settings decide whether this works at all:
- Callback URL. Studio, Authentication, Sign In / Providers shows the exact URL to register in the provider's console.
- CORS allowed origins. Studio, Authentication settings, Security · CORS.
If your app is served from a different origin than the API, that origin must
be listed. Otherwise the browser refuses the cookie the sign-in start sets,
and the provider callback comes back as
?error=state_mismatch.
Storage adapters
| Runtime | Adapter |
| -------- | ------------------------------------------------------------- |
| Browser | localStorageStorage() or cookieStorage() |
| SSR/Node | memoryStorage() (default), serverCookieStorage(cookieMethods) (getAll/setAll, see @motherbase/ssr for Next.js helpers), or a custom { get, set } adapter |
| Expo/RN | custom { get, set } backed by AsyncStorage |
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');
},
},
});Compatibility matrix
The matrix is not maintained by hand. It lives in
COMPATIBILITY.md and is generated by
apps/server/tests/sdk-contract.integration.test.ts, which runs every listed
supabase-js call against a live Motherbase server and a live Postgres. Each row
carries the id of the test that produced it.
Rows marked Not supported assert the current failure behaviour, so the day a
feature lands its test fails and the document has to be regenerated. That is the
mechanism that keeps this page from over-claiming (or under-claiming) again. CI
regenerates the file and fails if the committed copy differs.
Regenerate it locally with:
npm run test -w apps/server -- sdk-contractMore examples live in docs/sdk.md. Project Studio also
exposes a project-specific Connect page with the exact API URL, WebSocket URL and
project ID to paste into .env.
