Browse documentation

Motherbase is deliberately shaped like Supabase: the REST dialect is PostgREST-compatible, @motherbase/client mirrors supabase-js, and authorization is real PostgreSQL row-level security. That similarity is the point — most Supabase knowledge transfers unchanged.

It also means a model will confidently produce code that is almost right. This page is the list of places where "almost" is wrong, plus the machine-readable entry points.

Entry points

URLWhat it is
/llms.txtEvery documentation page as - [Title](url): description, grouped as in the sidebar
/llms-full.txtThe entire documentation set as one plain-text fetch (~500 KB)
/docs/contributing/known-issues/What the last audit found, and the one deliberate constraint. Read before asserting that something works
/api/docsSwagger UI on a running project — core endpoints only, not the REST table routes
/docs/sdk.mdThe SDK reference as raw Markdown, served by a running project
motherbase mcpThe project itself over MCP: schema, policies, the advisor lint, the RLS simulator, and DDL with --write. See MCP Server

llms-full.txt keeps the <Note>, <Warning> and <Flag> tags inline. They are not decoration: <Warning> marks a constraint that will bite, and <Flag> marks an open defect in the product itself (there are none right now — the tag exists so the next audit can say so in place rather than in a changelog). Do not strip them when summarizing.

Generate types first

Nearly every mistake below disappears if you generate the schema types and pass them to the client, because the compiler then rejects the wrong version:

motherbase gen types typescript > database.types.ts
import { createClient } from '@motherbase/client'
import type { Database } from './database.types'
 
const client = createClient<Database>(url, anonKey)

Relation names, row shapes, insert payloads, update payloads and RPC argument/return types are all checked from that point on. Do this before writing data-access code, not after.

What a Supabase-trained model gets wrong

The secret key is narrower than service_role

There is a secret key, and it does bypass RLS: pass mb_secret_... to createClient and requests run as the service_role Postgres role. There are three data-plane roles, anon, authenticated and service_role.

What does not carry over is the scope. A secret key opens the data plane and client.auth.admin.* (create, update, delete users, end their sessions). It does not open SQL, DDL, backups or settings — those stay behind operator authentication on /api/admin/* with an ADMIN session or the X-Platform-Token. Code that used Supabase's service key to run arbitrary SQL has to move to a migration, the CLI, or the admin API.

Consequence: "just use the service key on the server" fixes a data-access problem, not a schema one, and it is still the wrong answer to a policy problem in client code. Write the policy. See RLS and API Keys.

Key enforcement is also off by default: a project accepts callers that send no key at all until an operator turns it on. Do not assume a missing key is why a request failed.

RLS is the authorization layer, and a missing policy means zero rows

Tables created from Studio get RLS enabled automatically. With RLS on and no matching policy, anon and authenticated see nothing and every write fails — the API returns 403 with "Row level security or table privileges blocked this operation" (SQLSTATE 42501), not an empty success.

If you create a table, create its policies in the same change. A table with RLS on and no policy is not "secure by default", it is broken by default.

PATCH and DELETE require a filter

An unfiltered bulk mutation is rejected for non-admin callers. .update({...}) with no .eq() is a rejected request, not a table-wide update.

A write returns no rows unless you ask

A table write defaults to Prefer: return=minimal, as PostgREST does: insert(row) resolves to { data: null }. Chain .select() when you need the written representation — and the caller needs SELECT access to receive it. /rpc/ and /batch are the exception: they default to representation, because there the result is the call's whole purpose.

upsert is real, and so is nearly everything else

Older documentation claimed .rpc(), relational embedding, broadcast/presence channels and storage.list() were unsupported. They are all supported now. Check the SDK compatibility matrix rather than assuming a gap.

Filter values are untyped

Values are sent to Postgres untyped and coerced from the operand's type, exactly as in PostgREST. eq.42 matches a text column containing "42", and eq.null matches the literal string null — the only way to reach SQL NULL is is.null.

Storage objects are addressed by path

upload returns { path, id, fullPath }. Pass path to download, remove, getPublicUrl and createSignedUrl. A bare UUID is also accepted and resolves by file id. createSignedUrl(path, expiresIn) takes seconds (1 to 604800) and resolves to { signedUrl, expiresAt }. expiresAt is null when nothing expires — a public object, or a private one under the local driver. Check it rather than assuming.

Auth is Better Auth, not GoTrue

The SDK covers email/password, email OTP, password reset, OAuth, two-factor (client.auth.mfa.*), passkeys (client.auth.passkey.*), organizations and teams (client.auth.organization.*), runtime SSO provider management (client.auth.sso.*), SSO sign-in (client.auth.signInWithSSO) and user administration (client.auth.admin.*, secret key required). Magic links and per-user API keys exist on the server as Better Auth plugins but do not have a dedicated Motherbase SDK namespace. Anonymous sessions are supported when auth.methods.anonymous is enabled; they create a real user flagged is_anonymous. The SDK also supports CAPTCHA tokens and PKCE via flowType: 'pkce' and exchangeCodeForSession().

Two-factor differs from supabase.auth.mfa in three stated ways: enrolment requires the user's password, there is one TOTP factor rather than a factor list, and mfa.challenge() is a local no-op for TOTP because the code is already on the device.

Better Auth bearer sessions have no refresh token. refreshSession() re-validates the current session; code that stores and rotates a refresh_token has nothing to rotate.

client.hydrate() before first use

With a persistent storage adapter, the session is loaded asynchronously. Call await client.hydrate() once at startup or the first request after a reload runs as anon.

A correct minimal setup

-- Run in Studio → SQL editor
create table todo (
  id         uuid primary key default gen_random_uuid(),
  -- text, not uuid: auth.uid() returns text. Defaulting it means the client
  -- never sends user_id at all, so it cannot send someone else's.
  user_id    text not null default auth.uid(),
  title      text not null,
  done       boolean not null default false,
  created_at timestamptz not null default now()
);
 
alter table todo enable row level security;
 
create policy "todo owner read" on todo for select to authenticated
  using (auth.uid() = user_id);
 
create policy "todo owner write" on todo for all to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);
 
-- RLS filters rows; it does not make the scan fast.
create index todo_user_id_idx on todo (user_id);
await client.hydrate()
 
const { data, error } = await client
  .from('todo')
  .select('id,title,done')
  .eq('done', false)
  .order('created_at', { ascending: false })
  .limit(20)
 
if (error) console.error(error.code, error.status, error.message)

Nothing in the client filters by user_id — the policy does. That is the whole model: the query says what you want, the policy says what you may have.

Verifying instead of assuming

  • motherbase rls simulate --sql "select * from todo" --as anon — run a query as a given caller and see what RLS actually lets through.
  • motherbase rls lint — the security and performance advisor: tables open over REST, RLS on with no policy, SECURITY DEFINER functions callable by anon, unindexed policy filters.
  • .explain({ analyze: true }) on any query — admin sessions only.
Note

If you are writing an AGENTS.md or CLAUDE.md for a project built on Motherbase, the useful contents are: the generated database.types.ts path, "authorization is RLS, never add application-layer owner checks", and a link to /llms-full.txt. Everything else is discoverable from those.