DS DevShelfHub Projects · AI tools
Cheatsheets / Supabase
Cheatsheet · Dev tooling

Supabase Cheatsheet: Postgres, Auth, Storage and Realtime

By DevShelfHub

Postgres + RLS, supabase-js CRUD, auth (email/OAuth/OTP), Storage, Realtime channels, RPC functions, Edge Functions, CLI — the BaaS surface in one page.

89 items 9 min Postgres Auth RLS

Start hereQuick start · 6 you’ll reach for daily

CLI initsupabase init
Local devsupabase start
JS clientcreateClient(url, anonKey)
Readsb.from('todos').select('*')
Sign insb.auth.signInWithPassword({...})
Subscribechannel.on('postgres_changes', ...)

Target versions · paceVersions

Targets: @supabase/supabase-js ≥ 2.45 supabase CLI ≥ 1.190 Postgres 15 (managed)

Supabase is “Postgres + ergonomic SDK + managed services” — under the hood it’s vanilla Postgres 15, PostgREST for REST, GoTrue for auth, the Realtime listener for change feeds, Storage on top of S3, and Deno-based Edge Functions. Every Postgres feature works as-is (extensions, triggers, functions, RLS). Pin the JS client to a v2 minor — the API has been stable since v2, but error shapes still shift between patches.

CLI · local · JS clientSetup

bash
# 1. CLI — global install
brew install supabase/tap/supabase     # or: npm i -g supabase

supabase --version
supabase login                          # opens browser for the access token

# 2. New project — local stack (Docker)
supabase init                           # scaffold supabase/ folder
supabase start                          # boot Postgres + Studio + GoTrue + Realtime locally
supabase status                         # prints URLs + anon/service_role keys

# 3. Link to a cloud project
supabase link --project-ref xxxxxxxxxxxx
supabase db push                        # apply local migrations to the cloud DB

# 4. JS client (browser / Node / Bun / Deno)
npm i @supabase/supabase-js

# 5. Initialise the client
cat > supabase-client.ts <<'TS'
import { createClient } from '@supabase/supabase-js'

export const sb = createClient(
    import.meta.env.VITE_SUPABASE_URL!,
    import.meta.env.VITE_SUPABASE_ANON_KEY!,    // anon key — public, RLS-protected
)
TS

supabase ...Supabase CLI

supabase initScaffold the supabase/ folder with config + migrations.
supabase loginOpen browser; store the access token.
supabase link --project-ref xxxxxxxxxxxxBind the local repo to a cloud project.
supabase start · supabase stop · supabase statusLocal stack (Docker): Postgres, Studio, GoTrue, Realtime, Storage. Status prints anon/service_role keys.
supabase db pushApply migrations under supabase/migrations to the linked cloud DB.
supabase db resetRecreate the local DB from migrations + seed.sql. Idempotent.
supabase db diff -f add_todosGenerate a new migration from the diff between local schema and the last applied state.
supabase gen types typescript --linked > src/types.tsType-safe TypeScript types from the live schema.
supabase functions deploy helloPush an Edge Function to the cloud.
supabase secrets set OPENAI_KEY=sk-...Set a function secret. Read via Deno.env.get.

Postgres under the hoodDatabase basics

public schemaDefault home for your tables. Auto-exposed by PostgREST.
auth.usersManaged by GoTrue. Don’t modify. FK into it with REFERENCES auth.users(id).
anon key (frontend)Public. RLS enforced. Safe to ship in JS bundles.
service_role key (server)Never ship Bypasses RLS. Backend only.
supabase/migrations/<ts>_*.sqlPlain SQL migrations, timestamped, applied in order.
supabase/seed.sqlLocal-only seed data. Runs after migrations on db reset.
psql 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'Direct connection to the local stack.
CREATE EXTENSION pgvector / pg_cron / postgis ...All standard extensions available. pgvector is the one most teams reach for first.

Per-row authorisationRow-Level Security

ALTER TABLE todos ENABLE ROW LEVEL SECURITYRequired Without this, the table is wide open to the anon role.
CREATE POLICY p ON t FOR SELECT USING (user_id = auth.uid())Read policy. USING filters existing rows.
CREATE POLICY p ON t FOR INSERT WITH CHECK (user_id = auth.uid())Insert policy. WITH CHECK validates new rows.
CREATE POLICY p ON t FOR UPDATE USING (...) WITH CHECK (...)Update needs both: which rows to see and what the new row may look like.
CREATE POLICY p ON t FOR DELETE USING (user_id = auth.uid())Delete policy.
FOR ALL USING (...) WITH CHECK (...)Shorthand for SELECT + INSERT + UPDATE + DELETE.
auth.uid()UUID of the current authenticated user, NULL for anon.
auth.role()'anon' / 'authenticated' / 'service_role'.
auth.jwt() ->> 'role'Read a custom claim from the JWT.
DROP POLICY p ON tRemove a single policy.
SELECT * FROM pg_policies WHERE tablename = 't'Inspect what’s in effect right now.
sql
-- Owner-only access pattern: every row carries a user_id; users see only their own.
CREATE TABLE IF NOT EXISTS public.todos (
    id        bigserial PRIMARY KEY,
    user_id   uuid NOT NULL DEFAULT auth.uid() REFERENCES auth.users(id) ON DELETE CASCADE,
    title     text NOT NULL,
    done      boolean NOT NULL DEFAULT false,
    created   timestamptz NOT NULL DEFAULT now()
);

-- Step 1 — enable RLS (nothing in this table is visible until you do)
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;

-- Step 2 — narrow policies, one per operation, USING vs WITH CHECK
CREATE POLICY todos_select_own ON public.todos
    FOR SELECT USING (user_id = auth.uid());

CREATE POLICY todos_insert_own ON public.todos
    FOR INSERT WITH CHECK (user_id = auth.uid());

CREATE POLICY todos_update_own ON public.todos
    FOR UPDATE USING (user_id = auth.uid())
                WITH CHECK (user_id = auth.uid());

CREATE POLICY todos_delete_own ON public.todos
    FOR DELETE USING (user_id = auth.uid());

-- Inspect what's in effect
SELECT schemaname, tablename, policyname, cmd, qual, with_check
FROM   pg_policies
WHERE  tablename = 'todos';

CRUD · filters · RPCsupabase-js client

import { createClient } from '@supabase/supabase-js'Top-level import.
const sb = createClient(url, anonKey)RLS-protected client. One per app/process.
const { data, error } = await sb.from('todos').select('*')Every call returns { data, error }. Always check error first.
.select('id, title, profiles(name)')Embed an FK relation by name — PostgREST joins for you.
.eq, .neq, .gt, .gte, .lt, .lte, .like, .in, .containsFilter chain. One per condition.
.order('created', { ascending: false }).limit(20).range(0, 19)Sort + cap + pagination range.
.maybeSingle() / .single()Expect 0/1 or exactly 1 row. Errors otherwise.
.insert({ ... }) / .insert([...]).select()Insert one row or many; chain .select() to return the rows.
.upsert(row, { onConflict: 'id' })Upsert by a unique column.
.update({ done: true }).eq('id', id)Update + filter together.
.delete().eq('id', id)Delete by predicate.
await sb.rpc('match_documents', { embedding, k: 5 })Call a Postgres function. RLS still applies to the tables it touches.
javascript
import { sb } from './supabase-client'

// All calls return { data, error } — always check error first.
const { data: open, error: e1 } = await sb
    .from('todos')
    .select('id, title, created, profiles(name)')        // embed FK relation
    .eq('done', false)
    .order('created', { ascending: false })
    .range(0, 19)                                        // pagination — 20 rows
if (e1) throw e1

// Insert + return the row(s) you just wrote
const { data: created } = await sb
    .from('todos')
    .insert({ title: 'Write supabase cheatsheet' })
    .select()
    .single()

// Upsert by primary key
await sb.from('todos').upsert(
    { id: created.id, done: true },
    { onConflict: 'id' }
)

// Update + filter combined
await sb.from('todos').update({ done: true }).eq('id', created.id)

// Delete
await sb.from('todos').delete().eq('id', created.id)

// Call a Postgres function — RLS still applies on its tables
const { data: top } = await sb.rpc('top_todos', { uid: (await sb.auth.getUser()).data.user!.id, n: 5 })

GoTrue · sessions · OAuthAuth

await sb.auth.signUp({ email, password })Create an account. Email confirmation by default.
await sb.auth.signInWithPassword({ email, password })Standard login.
await sb.auth.signInWithOAuth({ provider: 'google', options: { redirectTo } })OAuth flow. Configure provider keys in the dashboard.
await sb.auth.signInWithOtp({ email })Magic link / one-time code.
await sb.auth.verifyOtp({ email, token, type: 'email' })Verify the 6-digit code from the email.
await sb.auth.getSession()Current session from local storage / cookies.
await sb.auth.getUser()Re-verifies with the server. Use in server actions / SSR.
sb.auth.onAuthStateChange((event, session) => ...)Subscribe to SIGNED_IN / SIGNED_OUT / TOKEN_REFRESHED / USER_UPDATED.
await sb.auth.updateUser({ password: '...' })Change own credentials.
await sb.auth.resetPasswordForEmail(email)Email a reset link.
await sb.auth.signOut()Local + server logout.
supabaseAdmin.auth.admin.createUser({...})Service-role only. Bypasses email confirmation.
javascript
import { sb } from './supabase-client'

// 1. Email + password
const { data: signup, error } = await sb.auth.signUp({
    email:    'ada@example.com',
    password: 'correct horse battery staple',
})
const { data: session } = await sb.auth.signInWithPassword({
    email: 'ada@example.com', password: 'correct horse battery staple',
})

// 2. OAuth — Google, GitHub, Apple, ...
await sb.auth.signInWithOAuth({
    provider: 'google',
    options:  { redirectTo: `${window.location.origin}/auth/callback` },
})

// 3. Magic link / OTP (no password)
await sb.auth.signInWithOtp({ email: 'ada@example.com' })
// On the callback page:
await sb.auth.verifyOtp({ email: 'ada@example.com', token: '123456', type: 'email' })

// 4. Read the live session (persisted in localStorage / cookies)
const { data: { session: cur } } = await sb.auth.getSession()
const { data: { user }      } = await sb.auth.getUser()

// 5. React to auth changes anywhere in the app
const { data: sub } = sb.auth.onAuthStateChange((event, sess) => {
    if (event === 'SIGNED_IN')  navigate('/app')
    if (event === 'SIGNED_OUT') navigate('/login')
})
// later: sub.subscription.unsubscribe()

await sb.auth.signOut()

Buckets · signed URLsStorage

await sb.storage.createBucket('avatars', { public: false })Create a private bucket.
await sb.storage.from('avatars').upload(path, file, { upsert: true })Upload (overwrites if exists). path like user_id/avatar.png.
await sb.storage.from('avatars').download(path)Returns a Blob.
await sb.storage.from('avatars').createSignedUrl(path, 60)Pre-signed URL valid for N seconds. Private buckets.
sb.storage.from('avatars').getPublicUrl(path)Public bucket only — no signing.
await sb.storage.from('avatars').remove([path])Delete one or many objects.
await sb.storage.from('avatars').list(prefix)List objects under a prefix.
.getPublicUrl(path, { transform: { width: 200, height: 200, resize: 'cover' } })Image transformation pipeline.
RLS on storage.objectsBuckets are just rows. Write policies on storage.objects like any other table.

Change feeds · broadcast · presenceRealtime

const channel = sb.channel('rooms:1')Open a named channel. Any unique name per tab.
.on('postgres_changes', { event, schema, table, filter }, cb)DB change feed. event: INSERT / UPDATE / DELETE / *.
filter: 'user_id=eq.42'Server-side row filter. Mirrors PostgREST predicate syntax.
.on('broadcast', { event: 'cursor' }, cb)Peer-to-peer messages over the channel.
channel.send({ type: 'broadcast', event: 'cursor', payload })Publish a broadcast event.
.on('presence', { event: 'sync' }, cb)Sync events: sync, join, leave.
await channel.track({ user_id: uid })Announce presence with arbitrary metadata.
channel.subscribe(status => ...)Required to start. status → SUBSCRIBED / CHANNEL_ERROR / TIMED_OUT.
sb.removeChannel(channel)Always clean up. Forgotten channels leak websocket subscriptions.
javascript
import { sb } from './supabase-client'

// Subscribe to INSERTs on todos for the current user.
const channel = sb
    .channel('todos:user')                              // any unique name per tab
    .on(
        'postgres_changes',
        {
            event:  'INSERT',
            schema: 'public',
            table:  'todos',
            filter: `user_id=eq.${userId}`,             // server-side filter
        },
        (payload) => {
            console.log('new row', payload.new)
        }
    )
    .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'todos', filter: `user_id=eq.${userId}` },
        (payload) => { console.log('updated', payload.old, '→', payload.new) }
    )
    .subscribe((status) => {
        if (status === 'SUBSCRIBED') console.log('listening')
    })

// Broadcast (peer-to-peer message bus) + presence (who is online)
channel
    .on('broadcast', { event: 'cursor' }, ({ payload }) => moveCursor(payload))
    .on('presence',  { event: 'sync'   }, () => setPeers(channel.presenceState()))

await channel.track({ user_id: userId, seen_at: new Date().toISOString() })
channel.send({ type: 'broadcast', event: 'cursor', payload: { x: 100, y: 120 } })

// Clean up when the component unmounts
sb.removeChannel(channel)

PL/pgSQL · SECURITY DEFINERDatabase functions & RPC

CREATE FUNCTION top_n(uid uuid) RETURNS SETOF todos AS $$ SELECT * FROM todos WHERE user_id = uid ORDER BY created DESC LIMIT 10 $$ LANGUAGE sql STABLEA function returning a table.
SECURITY DEFINERRuns as the function’s creator. Bypasses RLS — lock down with explicit checks.
SECURITY INVOKER (default)Runs as caller. RLS applies to everything it touches.
GRANT EXECUTE ON FUNCTION top_n(uuid) TO authenticatedAuthorise the role.
await sb.rpc('top_n', { uid })Call from the client. Args passed as a JSON object.
RETURNS TABLE (id bigint, score float) AS ...Tabular return with explicit column types.

Deno · deploy · invokeEdge Functions

supabase functions new helloScaffold under supabase/functions/hello/index.ts.
Deno.serve(async (req) => new Response('ok'))Body of an Edge Function. Web Fetch Request in / Response out.
supabase functions serve hello --env-file ./supabase/.envLocal run + watch.
supabase functions deploy helloPush to cloud. Cold-start ~50ms region-local.
await sb.functions.invoke('hello', { body: { name: 'Ada' } })Call from the JS client. Auth headers are forwarded automatically.
Deno.env.get('OPENAI_KEY')Read a secret set via supabase secrets set.

Full pipeline · ~30 linesEnd-to-end · Auth-gated todos

Signs in, inserts a row (RLS attaches it to the current user), subscribes to changes on only that user’s rows, completes the todo, cleans up.

javascript
// End-to-end: auth-gated todo app. ~30 lines, RLS keeps each user's todos private.
import { sb } from './supabase-client'

async function main() {
    // 1. Sign in (or sign up — adapt as needed)
    await sb.auth.signInWithPassword({ email: 'ada@example.com', password: '****' })

    // 2. Insert — user_id defaults to auth.uid() via the DEFAULT clause + RLS WITH CHECK
    const { data: created, error } = await sb
        .from('todos')
        .insert({ title: 'Ship supabase cheatsheet' })
        .select().single()
    if (error) throw error

    // 3. Subscribe to my own INSERT/UPDATE/DELETE events
    const channel = sb.channel('my-todos')
        .on('postgres_changes',
            { event: '*', schema: 'public', table: 'todos',
              filter: `user_id=eq.${(await sb.auth.getUser()).data.user!.id}` },
            (p) => console.log(p.eventType, p.new ?? p.old))
        .subscribe()

    // 4. List — RLS limits the result to my rows only
    const { data: open } = await sb.from('todos')
        .select('*').eq('done', false).order('created', { ascending: false })

    // 5. Complete the todo
    await sb.from('todos').update({ done: true }).eq('id', created.id)

    // 6. Tidy up
    sb.removeChannel(channel)
    await sb.auth.signOut()
}

main().catch(console.error)

Best practiceGood to know

Enable RLS on every user-facing table — from the very first migration. The anon key ships in the JS bundle. Without RLS, anyone reading the bundle can read your DB. Adding RLS later means staring at “why is my SELECT empty?” for an afternoon.
Default user_id columns to auth.uid(). user_id uuid DEFAULT auth.uid() + a WITH CHECK policy means clients can’t forge ownership even by accident.
Write the schema as migrations, not from the dashboard. supabase db diff generates a migration from your local schema — check it in. The dashboard is for inspecting, not authoring; otherwise environments drift.

Common trapsWatch out for

Never ship the service_role key. It bypasses RLS. Keep it in server-only secrets — Vercel env vars, Edge Function secrets, a server-side Next.js route. If it ever lands in a Git commit, rotate immediately.
ENABLE ROW LEVEL SECURITY with no policies = nothing visible. Easy to enable RLS and forget to add the SELECT policy — you’ll get empty reads with no error. Always pair ENABLE with at least one policy in the same migration.
Realtime channels leak if you don’t clean them up. A forgotten sb.removeChannel in a React unmount means a stale websocket sub forever. Wire it up in useEffect’s cleanup function.

Go deeperSee also

Supabase FAQ

What is Supabase?

Supabase is an open-source Firebase alternative built on PostgreSQL. It provides a hosted Postgres database, auto-generated REST and GraphQL APIs, authentication, file storage, real-time subscriptions, and edge functions — all configurable from a dashboard or the Supabase CLI. You can also self-host the entire stack using Docker.

What is Row-Level Security in Supabase?

Row-Level Security (RLS) is a PostgreSQL feature that filters table rows based on the current user's identity. In Supabase, enable RLS with ALTER TABLE ... ENABLE ROW LEVEL SECURITY, then write policies using the auth.uid() function to allow users to only read or write their own rows. The Supabase client sends a JWT on every request so the database can apply the correct policy.

How does Supabase Auth work?

Supabase Auth is a built-in authentication service supporting email/password, magic link, OAuth providers (Google, GitHub, etc.), and OTP. Call supabase.auth.signInWithPassword() or signInWithOAuth() from the client SDK. Supabase issues a JWT on sign-in and injects the user ID into the request context, which RLS policies can reference via auth.uid().

How do Supabase Realtime channels work?

Realtime channels use WebSockets to push database changes, broadcast messages, or sync presence state to connected clients. Subscribe to Postgres changes with supabase.channel('name').on('postgres_changes', {event: '*', schema: 'public', table: 'messages'}, handler).subscribe(). Broadcast and Presence channels are available without database involvement for ephemeral state like cursor positions or typing indicators.

What are Supabase Edge Functions?

Edge Functions are server-side TypeScript functions deployed globally on Deno runtime. They run close to the user for low latency and can access Supabase services using the service-role key or the user JWT passed in the Authorization header. Use them for webhooks, custom auth logic, third-party API calls, or any server-side computation that should not run in the browser.