supabase

Installation
SKILL.md

Supabase

Setup

npm install -g supabase          # or: brew install supabase/tap/supabase
supabase init                    # initialize local project
supabase link --project-ref <id> # link to remote project

npm install @supabase/supabase-js  # JS/TS client
# pip install supabase             # Python client

Client initialization:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)

// Server-side with elevated privileges (bypasses RLS, never expose to client)
const supabaseAdmin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)

With generated types:

import { Database } from './types/database'
const supabase = createClient<Database>(url, key)

Database

create table public.posts (
  id uuid default gen_random_uuid() primary key,
  title text not null,
  content text,
  user_id uuid references auth.users(id) on delete cascade not null,
  created_at timestamptz default now() not null
);

Migrations and Types

supabase migration new create_posts_table   # create migration file
supabase db push                             # apply migrations to remote
supabase db pull                             # pull remote schema into migration
supabase db reset                            # reset local DB, rerun migrations
supabase gen types typescript --linked > src/types/database.ts  # generate types

Migration files live in supabase/migrations/ with timestamped filenames.

Row Level Security (RLS)

Always enable RLS on tables exposed to the client. Key functions: auth.uid() returns current user ID, auth.jwt() returns full JWT claims. using controls which existing rows are visible; with check controls which new/modified rows are allowed.

alter table public.posts enable row level security;

create policy "Public read access" on public.posts
  for select using (true);

create policy "Users can insert own posts" on public.posts
  for insert to authenticated
  with check (auth.uid() = user_id);

create policy "Users can update own posts" on public.posts
  for update to authenticated
  using (auth.uid() = user_id) with check (auth.uid() = user_id);

create policy "Users can delete own posts" on public.posts
  for delete to authenticated
  using (auth.uid() = user_id);

Authentication

// Email/password sign up
const { data, error } = await supabase.auth.signUp({
  email: 'user@example.com', password: 'secure-password',
})

// Email/password sign in
const { data, error } = await supabase.auth.signInWithPassword({
  email: 'user@example.com', password: 'secure-password',
})

// OAuth (github, google, apple, discord, etc.)
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: { redirectTo: 'https://yourapp.com/auth/callback' },
})

// Magic link
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
  options: { emailRedirectTo: 'https://yourapp.com/welcome' },
})

// Phone OTP
const { data, error } = await supabase.auth.signInWithOtp({ phone: '+15551234567' })
const { data, error } = await supabase.auth.verifyOtp({
  phone: '+15551234567', token: '123456', type: 'sms',
})

Auth Helpers

const { data: { user } } = await supabase.auth.getUser()
const { data: { session } } = await supabase.auth.getSession()
await supabase.auth.signOut()

const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
  console.log(event, session)
})
subscription.unsubscribe()

Storage

// Create bucket (requires service role or appropriate policies)
await supabase.storage.createBucket('avatars', {
  public: false, fileSizeLimit: 1048576, allowedMimeTypes: ['image/png', 'image/jpeg'],
})

// Upload
await supabase.storage.from('avatars').upload('user1/avatar.png', file, {
  cacheControl: '3600', upsert: true,
})

// Download
const { data } = await supabase.storage.from('avatars').download('user1/avatar.png')

// Signed URL (temporary, private buckets)
const { data } = await supabase.storage.from('avatars').createSignedUrl('user1/avatar.png', 3600)

// Public URL (public buckets only)
const { data } = supabase.storage.from('avatars').getPublicUrl('user1/avatar.png')

Storage policies use the storage.objects table:

create policy "Users upload own avatars" on storage.objects
  for insert to authenticated
  with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);

create policy "Public avatar access" on storage.objects
  for select using (bucket_id = 'avatars');

Realtime

Enable realtime for a table: alter publication supabase_realtime add table public.posts;

Postgres Changes Subscription

const channel = supabase.channel('posts-changes')
  .on('postgres_changes', { event: '*', schema: 'public', table: 'posts' },
    (payload) => console.log('Change:', payload))
  .subscribe()

// Filter by event type
supabase.channel('new-posts')
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' },
    (payload) => console.log('New:', payload.new))
  .subscribe()

supabase.removeChannel(channel) // unsubscribe

Presence

const channel = supabase.channel('room-1')
channel
  .on('presence', { event: 'sync' }, () => console.log(channel.presenceState()))
  .on('presence', { event: 'join' }, ({ key, newPresences }) => console.log('Joined:', newPresences))
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => console.log('Left:', leftPresences))
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({ user_id: 'user-1', online_at: new Date().toISOString() })
    }
  })

Broadcast

const channel = supabase.channel('room-1')
channel.on('broadcast', { event: 'cursor-pos' }, (payload) => console.log(payload)).subscribe()
channel.send({ type: 'broadcast', event: 'cursor-pos', payload: { x: 100, y: 200 } })

Edge Functions

Deno-based server-side TypeScript functions.

supabase functions new my-function      # create
supabase functions serve my-function    # local dev
supabase functions deploy my-function   # deploy
supabase secrets set MY_API_KEY=value   # set secret
supabase secrets list                   # list secrets
// supabase/functions/my-function/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!,
    { global: { headers: { Authorization: req.headers.get('Authorization')! } } }
  )
  const { data } = await supabase.from('posts').select('*')
  return new Response(JSON.stringify(data), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  })
})

Invoke from client:

const { data, error } = await supabase.functions.invoke('my-function', { body: { name: 'world' } })

Client Library Queries

Supabase auto-generates a PostgREST API from your schema. The client library wraps it.

// Select
const { data } = await supabase.from('posts').select('*')
const { data } = await supabase.from('posts').select('id, title')
const { data } = await supabase.from('posts').select('id, title, comments(id, body)')  // join
const { count } = await supabase.from('posts').select('*', { count: 'exact', head: true })

// Insert
const { data } = await supabase.from('posts')
  .insert({ title: 'Hello', content: 'World', user_id: userId }).select()
const { data } = await supabase.from('posts')
  .insert([{ title: 'A', user_id: userId }, { title: 'B', user_id: userId }]).select()

// Update
const { data } = await supabase.from('posts')
  .update({ title: 'New Title' }).eq('id', postId).select()

// Delete
await supabase.from('posts').delete().eq('id', postId)

// Filters
const { data } = await supabase.from('posts').select('*')
  .eq('user_id', userId)        // equals
  .neq('status', 'draft')       // not equals
  .gt('views', 100)             // greater than
  .lt('views', 1000)            // less than
  .like('title', '%hello%')     // case-sensitive pattern
  .ilike('title', '%hello%')    // case-insensitive pattern
  .in('status', ['published', 'archived'])
  .is('deleted_at', null)       // null check
  .order('created_at', { ascending: false })
  .range(0, 9)                  // pagination (first 10)
  .limit(10)
  .single()                     // expect exactly one row

Database Functions and RPC

create or replace function get_posts_by_author(author_id uuid)
returns setof posts language sql security definer as $$
  select * from posts where user_id = author_id order by created_at desc;
$$;
const { data } = await supabase.rpc('get_posts_by_author', { author_id: userId })

security definer bypasses RLS. security invoker (default) respects caller RLS policies.

CLI Reference

supabase start           # start local stack (Postgres, Auth, Storage, Studio)
supabase stop            # stop local stack
supabase status          # show local URLs and keys
supabase db push         # apply migrations to remote
supabase db pull         # pull remote schema
supabase db reset        # reset local DB
supabase db lint         # lint SQL
supabase db diff         # diff local vs remote
supabase migration new <name>
supabase migration list
supabase gen types typescript --linked > types/database.ts
supabase functions new <name>
supabase functions serve
supabase functions deploy <name>
supabase secrets set KEY=value
supabase secrets list

Local Development

supabase start launches the full stack. Studio runs at http://localhost:54323. Use local keys in .env.local:

SUPABASE_URL=http://localhost:54321
SUPABASE_ANON_KEY=<anon-key-from-supabase-start>

Common Patterns

User Profiles with Auto-Create Trigger

create table public.profiles (
  id uuid references auth.users(id) on delete cascade primary key,
  display_name text, avatar_url text, updated_at timestamptz default now()
);
alter table public.profiles enable row level security;

create policy "Public read" on public.profiles for select using (true);
create policy "Own update" on public.profiles for update to authenticated using (auth.uid() = id);

create or replace function public.handle_new_user() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
  insert into public.profiles (id, display_name)
  values (new.id, new.raw_user_meta_data ->> 'full_name');
  return new;
end;
$$;

create trigger on_auth_user_created after insert on auth.users
  for each row execute function public.handle_new_user();

File Uploads with Auth

async function uploadAvatar(userId: string, file: File) {
  const path = `${userId}/${Date.now()}-${file.name}`
  const { error } = await supabase.storage.from('avatars').upload(path, file, { upsert: true })
  if (error) throw error
  const { data } = supabase.storage.from('avatars').getPublicUrl(path)
  await supabase.from('profiles').update({ avatar_url: data.publicUrl }).eq('id', userId)
  return data.publicUrl
}

Realtime Chat

async function sendMessage(channelId: string, content: string) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')
  return supabase.from('messages').insert({ channel_id: channelId, content, user_id: user.id })
}

function subscribeToMessages(channelId: string, onMessage: (msg: any) => void) {
  return supabase.channel(`messages:${channelId}`)
    .on('postgres_changes', {
      event: 'INSERT', schema: 'public', table: 'messages',
      filter: `channel_id=eq.${channelId}`,
    }, (payload) => onMessage(payload.new))
    .subscribe()
}
Related skills

More from 1mangesh1/dev-skills-collection

Installs
1
GitHub Stars
3
First Seen
Apr 14, 2026