Browse documentation

This guide builds a todo app from Project Studio: create a table with SQL, add an owner-scoped RLS policy, copy connection values, then use the Supabase-compatible SDK. Each user sees, edits, and deletes only their own todos.

A live demo running against a backend is available on the Example page.

1. Create the table

Open the project from the Platform Console, then run this in Project Studio -> Database -> SQL:

CREATE TABLE todo (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title text NOT NULL,
  done boolean NOT NULL DEFAULT false,
  user_id text NOT NULL DEFAULT auth.uid(),
  created_at timestamptz NOT NULL DEFAULT now()
);
 
ALTER TABLE todo ENABLE ROW LEVEL SECURITY;

user_id defaults to auth.uid() — the JWT sub claim of the caller — so a row is stamped with its owner automatically on insert; the client never sends user_id. Making it NOT NULL guarantees every row has an owner, which the policy below relies on.

Motherbase discovers the table and exposes:

GET    /rest/v1/todo
POST   /rest/v1/todo
PATCH  /rest/v1/todo?id=eq.<id>
DELETE /rest/v1/todo?id=eq.<id>

2. Add an owner-scoped policy

Open Project Studio -> Database -> SQL and create a real PostgreSQL RLS policy. One FOR ALL policy covers read, insert, update, and delete:

CREATE POLICY "todo owner access"
ON todo
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
  • TO authenticated restricts the policy to signed-in requests. There is deliberately no anon policy, so a signed-out request matches no policy and RLS blocks it — no reads, no writes.
  • USING (auth.uid() = user_id) gates the rows a caller can see, update, or delete — only rows they own.
  • WITH CHECK (auth.uid() = user_id) gates the rows a caller can write — you can't insert or move a row to another owner.
Note

Why owner-only, and why no anon policy? A pure anonymous request carries no identity (auth.uid() is null), so there is no stable "self" to scope anonymous rows to — every anon caller would collapse into one shared bucket. Requiring a session gives each row a real owner and keeps the isolation guarantee honest. If you genuinely want a public board, add a separate TO anon USING (true) policy and accept that anon rows are shared by everyone.

Note

Policies are native PostgreSQL RLS. You can also manage them from Project Studio -> Policies, which writes real PostgreSQL policies behind the UI.

3. Copy connection values

Open Project Studio -> Connect and copy the project API URL and SDK install command into your app environment:

NEXT_PUBLIC_MOTHERBASE_URL=http://todo.localhost
NEXT_PUBLIC_MOTHERBASE_ANON_KEY=optional-anon-key

4. Install the SDK

npm install @motherbase/client
npm install socket.io-client # optional, only for realtime

Initialize once in your app:

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()

In a Next.js App Router Server Component or Route Handler, bind the SDK to Next's cookie store:

import { cookies } from 'next/headers'
import { createClient, serverCookieStorage } from '@motherbase/client'
 
export async function createServerClient() {
  const store = await cookies()
 
  return createClient(
    process.env.NEXT_PUBLIC_MOTHERBASE_URL!,
    process.env.NEXT_PUBLIC_MOTHERBASE_ANON_KEY,
    {
      storage: serverCookieStorage({
        getAll: () => store.getAll(),
        setAll: (list) =>
          list.forEach(({ name, value, options }) => store.set(name, value, options)),
      }),
    },
  )
}

5. CRUD with REST

const { data: todos, error: listError } = await client
  .from('todo')
  .select('*')
  .order('created_at', { ascending: false })
 
const { data: created, error: createError } = await client
  .from('todo')
  .insert({ title: 'Buy milk', done: false })
 
const todo = created?.[0]
 
if (todo) {
  await client.from('todo').update({ done: !todo.done }).eq('id', todo.id)
  await client.from('todo').delete().eq('id', todo.id)
}

PATCH and DELETE must include filters for non-admin callers. Motherbase rejects unfiltered bulk writes.

6. Realtime updates

REST writes emit table events automatically.

const channel = client
  .channel('room-todo')
  .on('postgres_changes', { event: '*', schema: 'public', table: 'todo' }, (payload) => {
    if (payload.eventType === 'INSERT') {
      setTodos(prev => [payload.new, ...prev])
    }
 
    if (payload.eventType === 'UPDATE') {
      setTodos(prev => prev.map(t => t.id === payload.new.id ? payload.new : t))
    }
 
    if (payload.eventType === 'DELETE') {
      setTodos(prev => prev.filter(t => t.id !== payload.old.id))
    }
  })
  .subscribe()
 
await client.removeChannel(channel)

7. Auth is required

With the owner-scoped policy, every read and write needs a session — a signed-out client sees an empty list and its writes are rejected by RLS. Sign up or sign in first:

await client.auth.signUp({ email, password })        // first time
await client.auth.signInWithPassword({ email, password })
 
await client.from('todo').insert({
  title: 'My todo',
  done: false, // user_id is filled in server-side from auth.uid()
})

React to session changes to load or clear data:

const { data: { subscription } } = client.auth.onAuthStateChange((event, session) => {
  // event: 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED'
  //      | 'USER_UPDATED' | 'MFA_CHALLENGE_VERIFIED'
  if (session) loadTodos()
  else setTodos([])
})
 
// on unmount:
subscription.unsubscribe()

Motherbase scopes reads and writes to rows where user_id matches auth.uid(), so the same query returns a different list for each user — the isolation is enforced by Postgres, not by client-side filtering.

Why this stack

The choices below are what make the todo app a few lines of client code instead of a backend project.

Native PostgreSQL RLS, not a JS policy layer

Access control is expressed as real CREATE POLICY rules evaluated by Postgres inside the same transaction as the query. There is no application-side rules engine to keep in sync, no ORM where clause to remember, and no way for a forgotten filter to leak another user's rows. The database is the single source of truth (pg_policies), so the REST API, the SDK, a psql session, and a future service all get the same enforcement for free. The trade-off — policies are SQL, which is less familiar than JS — is worth it: authorization bugs are the most expensive kind, and centralizing them in the engine that already owns the data removes an entire class of them.

A Supabase-compatible SDK surface

@motherbase/client mirrors supabase-js — from(table).select(), auth.signInWithPassword(), channel().on('postgres_changes', …). That is a deliberate compatibility choice, not imitation: it means existing Supabase apps, snippets, and muscle memory port over with minimal churn, and the migrate-from-supabase path is mostly a URL swap. Every call resolves to a uniform { data, error } envelope and never throws for expected API errors, so error handling is explicit and consistent across auth, REST, storage, and realtime.

PostgREST-style REST over a bespoke API

Data access goes through /rest/v1/:model with a PostgREST-style query dialect (?id=eq.<id>, order, limit). Reusing an established, table-driven convention means new tables are instantly queryable with no per-endpoint code, filters map directly onto SQL, and the wire format is already documented by the wider PostgREST ecosystem. The server rejects unfiltered bulk PATCH/DELETE for non-admins, so a missing filter fails loudly instead of rewriting the whole table.

Bearer-token auth, no refresh-token dance

Auth is backed by Better Auth session tokens: sign-in returns a single bearer token that is valid until it expires, after which the user re-authenticates. There is no separate refresh token to rotate and store, which removes a common source of session bugs and XSS blast-radius. On the client, cookieStorage() (or serverCookieStorage() in a Server Component) persists that token so the session survives reloads and works on both sides of an App Router boundary.

socket.io for realtime, loaded on demand

Realtime multiplexes postgres_changes, broadcast, and presence over one socket.io connection. socket.io buys automatic reconnection, heartbeats, and a polling fallback for hostile networks — reliability that raw WebSockets leave to you. It is imported lazily only when you open a channel, so REST-only apps never pay for the transport. REST writes emit table change events automatically, which is why the demo's list updates live without any extra publish step.

Next steps