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 authenticatedrestricts the policy to signed-in requests. There is deliberately noanonpolicy, 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.
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.
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-key4. Install the SDK
npm install @motherbase/client
npm install socket.io-client # optional, only for realtimeInitialize 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
- Add file attachments via the Storage API
- Manage project resources from the CLI
- Deploy your instance following the Deploy guide