Tutorials

Supabase for Startups: The Complete Guide — From Zero to Production

What is Supabase?

Supabase is an open-source platform for building on PostgreSQL. It bundles a database, authentication (GoTrue) with passkeys (public beta since May 2026), an automatic REST API (PostgREST), GraphQL (pg_graphql, opt-in), Realtime WebSocket subscriptions, S3-compatible storage, Edge Functions (Deno runtime), pgvector for AI/vector search, and branching for preview environments. It's a Firebase alternative without vendor lock-in. Free tier: 500 MB database, 50K MAU. Pro: $25/month, 8 GB database, 100K MAU.

TL;DR

  • -Supabase is an open-source Backend-as-a-Service built on PostgreSQL. Full SQL, row-level security, automatic REST API generation from your database schema. No vendor lock-in — you can self-host with Docker
  • -Architecture: PostgreSQL (core) + PostgREST (REST API) + GoTrue (auth) + Realtime (WebSocket) + Storage (S3-compatible) + Edge Functions (Deno) + pgvector (AI embeddings)
  • -RLS is the central security concept. Policies are written in SQL and enforced at the database level. Turn it on from day one — retrofitting RLS into a live application is much harder
  • -Edge Functions are serverless on Deno/TypeScript. Ideal for webhooks, AI API integrations, payment processing. For heavy logic, PostgreSQL functions are faster. Cold start dropped 97% in 2025
  • -Pricing: Free (500 MB, 50K MAU), Pro ($25/month, 8 GB, 100K MAU), Team ($599/month, SOC2, SSO), Enterprise (custom). Self-hosted is free
  • -Branching gives you a preview environment for every pull request. A separate Supabase instance, its own credentials, no production data. A must-have once a startup has a team

Supabase is an open-source platform built on PostgreSQL. A full Postgres instance with add-ons: authentication, realtime subscriptions, file storage, serverless functions, vector search. All of it out of the box, behind a single SDK.

Over 2025–2026, Supabase grew from a “Firebase alternative” into a platform in its own right. 100K+ GitHub stars, a $10.5B valuation after a $500M Series F round in June 2026, and more than 2 million weekly npm downloads.

The highlights from 2025–2026: a new API key model (publishable/secret instead of anon/service_role), security-by-default — new tables are no longer exposed automatically through the Data API without an explicit GRANT — branching 2.0 with no Git dependency, passkeys (public beta), PostgREST v14 (+20% RPS), and a 97% cut in Edge Function cold starts. pg_graphql is now opt-in for new projects. Supabase MCP also showed up — agents can read the schema, apply migrations, and check logs straight from the IDE.

Why startups pick Supabase: PostgreSQL’s 30-year track record, full control over your data, self-hosting via Docker — no vendor lock-in. The free tier covers an MVP and your first few thousand users.

Supabase vs Firebase: an honest comparison

CriterionSupabaseFirebase
DatabasePostgreSQL (relational, SQL)Firestore (NoSQL, documents)
QueriesFull SQL, JOINs, window functionsLimited queries, no JOINs
AuthGoTrue: email, OAuth, magic link, phone, SSOFirebase Auth: email, OAuth, phone
StorageS3-compatible, RLS at the bucket levelCloud Storage for Firebase
FunctionsEdge Functions (Deno/TypeScript)Cloud Functions (Node.js)
RealtimeWebSocket subscriptions to DB changesBuilt-in realtime sync
Vector/AIpgvector — native vector searchNone (needs Vertex AI separately)
PricingPredictable, tiered plansPay-per-read/write, hard to forecast
Open sourceYes, self-hostable via DockerNo
Vendor lock-inMinimal (standard PostgreSQL)High (proprietary database)
GraphQLBuilt in (pg_graphql, opt-in for new projects)None (needs Apollo or similar)

When Firebase is the better choice

  • Mobile-first products with offline sync — Firestore’s offline persistence works out of the box
  • Realtime as the product’s core — collaborative documents, multiplayer, live auctions. Firebase Realtime Database is built for exactly this
  • Google ecosystem — you’re already on GCP, using Firebase Analytics, Cloud Messaging
  • Fast prototyping without SQL knowledge — Firestore is easier for frontend developers to pick up

When Supabase is the better choice

  • Complex data relationships — foreign keys, JOINs, aggregations, window functions
  • Control over your data — self-hosting, migrations, standard SQL
  • AI features — pgvector for embeddings and semantic search without a separate service
  • Predictable costs — fixed pricing instead of pay-per-operation
  • Security — Row Level Security at the database level, SOC2 on the Team plan
  • Full-text search — built into PostgreSQL, no need for Algolia or Elasticsearch

Supabase architecture

Supabase is a set of open-source tools built around PostgreSQL, wired together through the Kong API Gateway.

┌─────────────────────────────────────────────────┐
│                  Kong API Gateway                │
│         (routing, rate limiting)                 │
├──────┬──────┬──────┬──────┬──────┬──────┬───────┤
│ Post │ Go   │ Real │ Stor │ pg_  │ Edge │ pg_   │
│ gREST│ True │ time │ age  │ meta │ Func │graphql│
│      │      │      │      │      │      │       │
│ REST │ Auth │ WS   │ S3   │ Mgmt │ Deno │GraphQL│
│ API  │ API  │ Sub  │ API  │ API  │ RT   │ API   │
└──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴───┬───┘
   │      │      │      │      │      │       │
   └──────┴──────┴──────┴──────┴──────┴───────┘

              ┌────────┴────────┐
              │   PostgreSQL    │
              │  + extensions   │
              │  (pgvector,     │
              │   pg_cron,      │
              │   pg_net, ...)  │
              └─────────────────┘

Components

PostgreSQL — the core. All data, all logic. Supabase doesn’t abstract the database away — it gives you direct access to it. Version 15+ with extensions: pgvector, pg_cron, pg_net, pg_stat_statements.

PostgREST — automatically generates a REST API from your database schema. Create a table, the API is ready. Supports filtering, pagination, and nested resources via foreign keys. No need to hand-write CRUD endpoints. Version 14 (2026) added JWT caching and, per Supabase’s benchmarks, delivered roughly a 20% RPS boost on GET requests.

pg_graphql — a GraphQL API generated directly from your PostgreSQL schema. An alternative to PostgREST for teams that prefer GraphQL. As of 2026 it’s opt-in for new projects: GraphQL is no longer enabled automatically — you activate it from the Dashboard.

GoTrue — the authentication server. A fork of Netlify’s project, rewritten and maintained by Supabase. Email/password, OAuth (Google, GitHub, Apple, Discord, and others), magic link, phone (SMS), SSO (SAML).

Realtime — an Elixir server that watches the PostgreSQL replication log and streams changes over WebSocket. Three modes: Postgres Changes (subscribe to INSERT/UPDATE/DELETE), Broadcast (arbitrary messages between clients), and Presence (tracking who’s online).

Storage — S3-compatible file storage. Supports RLS policies at the bucket and object level. On-the-fly image transformations (resize, crop). CDN via a built-in cache.

Edge Functions — serverless functions on the Deno runtime. TypeScript/JavaScript. Deploy via CLI or Dashboard. Isolated execution context, built-in secrets support.

pgvector — a PostgreSQL extension for storing and searching vector embeddings. Semantic search, RAG, AI features — without a separate vector database.

Quick start: from zero to a working app

Creating a project

  1. Sign up at supabase.com
  2. “New Project” → name, database password, region (pick the one closest to your users)
  3. The project spins up in 1–2 minutes. Supabase provisions a dedicated PostgreSQL instance

Setting up the schema

Through the SQL Editor in the Dashboard:

-- Todos table
create table todos (
  id bigint generated always as identity primary key,
  user_id uuid references auth.users(id) not null,
  title text not null,
  description text,
  is_complete boolean default false,
  created_at timestamptz default now()
);

-- Enable RLS
alter table todos enable row level security;

-- Policy: users can only see their own todos
create policy "Users can view own todos"
  on todos for select
  using (auth.uid() = user_id);

-- Policy: users can only create todos as themselves
create policy "Users can create own todos"
  on todos for insert
  with check (auth.uid() = user_id);

-- Policy: users can only update their own todos
create policy "Users can update own todos"
  on todos for update
  using (auth.uid() = user_id);

-- Policy: users can only delete their own todos
create policy "Users can delete own todos"
  on todos for delete
  using (auth.uid() = user_id);

Connecting a client

Install:

npm install @supabase/supabase-js

Initialize:

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

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

The anon-key is a public key. It’s safe in client-side code because RLS restricts access. Don’t confuse it with the service_role key — that one bypasses RLS and should only ever live on the server.

New keys (2025+). In new projects, use sb_publishable_* instead of anon-key, and sb_secret_* instead of service_role. Legacy keys keep working through the end of 2026, then get removed. Check Settings → API Keys.

A todo app in 20 minutes

Full CRUD:

// Sign up a user
const { data: authData, error: authError } = await supabase.auth.signUp({
  email: '[email protected]',
  password: 'secure-password-123'
})

// Sign in
const { data: signInData, error: loginError } = await supabase.auth.signInWithPassword({
  email: '[email protected]',
  password: 'secure-password-123'
})

// Create a todo
const { data: newTodo, error: createError } = await supabase
  .from('todos')
  .insert({
    title: 'Set up Supabase',
    description: 'Create a project and connect the client',
    user_id: signInData.user.id
  })
  .select()
  .single()

// Fetch todos (RLS automatically filters by user_id)
const { data: todos, error: fetchError } = await supabase
  .from('todos')
  .select('*')
  .order('created_at', { ascending: false })

// Update a todo
const { error: updateError } = await supabase
  .from('todos')
  .update({ is_complete: true })
  .eq('id', newTodo.id)

// Delete a todo
const { error: deleteError } = await supabase
  .from('todos')
  .delete()
  .eq('id', newTodo.id)

Every request automatically attaches the JWT from the session. RLS policies check auth.uid() on the database side. The client can’t get someone else’s data — even by tampering with the request.

Authentication

Sign-in methods

GoTrue supports every common method. In May 2026, passkeys reached public beta — biometric sign-in (Face ID, Touch ID) or a hardware key via WebAuthn. Users register passkeys through the Dashboard, CLI, or Management API. The API is stable but still marked beta:

// Email + Password
await supabase.auth.signUp({ email, password })
await supabase.auth.signInWithPassword({ email, password })

// Magic Link (passwordless email)
await supabase.auth.signInWithOtp({ email })

// OAuth (Google, GitHub, Apple, Discord, and others)
await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'https://your-app.com/auth/callback'
  }
})

// Phone (SMS)
await supabase.auth.signInWithOtp({ phone: '+79001234567' })

// Verify the SMS code
await supabase.auth.verifyOtp({
  phone: '+79001234567',
  token: '123456',
  type: 'sms'
})

// Anonymous sign-in — for registration-free onboarding, carts, demo mode
const { data, error } = await supabase.auth.signInAnonymously()
// Anonymous users get the authenticated role — RLS policies apply
// Convert to a full account with linkIdentity() whenever they choose to register

Session management

// Current session
const { data: { session } } = await supabase.auth.getSession()

// Current user
const { data: { user } } = await supabase.auth.getUser()

// Subscribe to auth state changes
supabase.auth.onAuthStateChange((event, session) => {
  if (event === 'SIGNED_IN') {
    // User signed in
  }
  if (event === 'SIGNED_OUT') {
    // User signed out
  }
  if (event === 'TOKEN_REFRESHED') {
    // Token refreshed
  }
})

// Sign out
await supabase.auth.signOut()

Row Level Security — the central concept

RLS is the main thing that sets Supabase apart from Firebase Security Rules. Policies are written in SQL and run directly inside PostgreSQL — at the database level, not in middleware or application server code.

The principle is simple: a table without RLS is open to everyone; with RLS enabled, it’s closed by default. Access is granted through policies.

-- Enable RLS
alter table profiles enable row level security;

-- Basic policies for user profiles

-- Profile visible to everyone (public data)
create policy "Public profiles are viewable by everyone"
  on profiles for select
  using (true);

-- Users can only edit their own profile
create policy "Users can update own profile"
  on profiles for update
  using (auth.uid() = id)
  with check (auth.uid() = id);

-- Users can only create their own profile
create policy "Users can insert own profile"
  on profiles for insert
  with check (auth.uid() = id);

More advanced policies:

-- Role-based access (RBAC)
create policy "Admins can do anything"
  on todos for all
  using (
    exists (
      select 1 from profiles
      where profiles.id = auth.uid()
      and profiles.role = 'admin'
    )
  );

-- Access to organization data
create policy "Members can view org data"
  on projects for select
  using (
    exists (
      select 1 from org_members
      where org_members.org_id = projects.org_id
      and org_members.user_id = auth.uid()
    )
  );

-- Time-window-based access
create policy "Can only edit recent records"
  on posts for update
  using (
    auth.uid() = author_id
    and created_at > now() - interval '24 hours'
  );

The new API key model (2025+)

Supabase moved away from legacy JWT-based keys to a new system. Legacy anon and service_role keys are deprecated and will be removed by the end of 2026:

KeyReplacementWhere to use it
anon keysb_publishable_*Public client code, browser, mobile app
service_role keysb_secret_*Trusted backend only: Edge Functions, server code

The publishable key carries the same limited permissions as the anon key — RLS still applies. The secret key bypasses RLS, just like service_role did.

To migrate: Settings → API Keys → create publishable and secret keys, update your code.

Security-by-default (from May 2026)

Breaking change. New tables in the public schema are no longer exposed through the Data API automatically. You need an explicit GRANT:

-- After creating a table — explicitly grant access to the roles that need it
grant select, insert, update, delete
  on table todos
  to anon, authenticated;

-- And enable RLS — without it, the table is open to any role with a grant
alter table todos enable row level security;

Before May 30, 2026, Supabase automatically granted SELECT, INSERT, UPDATE, DELETE on every table in public. Now it doesn’t. For existing projects, the change takes effect on October 30, 2026. If a table isn’t visible through the API, check its grants via the Security Advisor in the Dashboard.

Security best practices

  1. Enable RLS on every table — no exceptions. One forgotten table without RLS is a data leak
  2. Explicit GRANT after creating a table — starting May 2026, tables aren’t exposed in the Data API without explicit grants
  3. Never use the service_role / secret key in client code — it bypasses every RLS policy
  4. Check with checkusing filters reads, with check validates writes. You need both
  5. Test your policies — the SQL Editor in the Dashboard lets you check RLS behavior as a specific user
  6. Use auth.uid() instead of trusting a user_id sent by the client — the client can’t forge auth.uid()
  7. Auth Hooks — custom handlers for access tokens, email/SMS delivery, MFA verification. Let you add custom claims to the JWT without piling everything into user_metadata
  8. RLS Tester (Feature Preview) — a new Dashboard tool: runs a SELECT as an anon/authenticated user and shows which policy fired. Handy for debugging without manually running SET role. Currently SELECT-only

Working with data

CRUD operations

// SELECT with filters
const { data } = await supabase
  .from('products')
  .select('id, name, price, category:categories(name)')
  .gte('price', 100)
  .lte('price', 500)
  .order('price', { ascending: true })
  .range(0, 9) // pagination: first 10 records

// INSERT multiple records
const { data } = await supabase
  .from('products')
  .insert([
    { name: 'Widget A', price: 150 },
    { name: 'Widget B', price: 250 }
  ])
  .select()

// UPSERT (insert or update)
const { data } = await supabase
  .from('products')
  .upsert(
    { id: 1, name: 'Widget A', price: 175 },
    { onConflict: 'id' }
  )
  .select()

Relations and nested queries

PostgREST automatically resolves relationships through foreign keys:

// Fetch posts with author and comments
const { data } = await supabase
  .from('posts')
  .select(`
    id,
    title,
    content,
    author:profiles(id, name, avatar_url),
    comments(
      id,
      text,
      user:profiles(name)
    )
  `)
  .eq('published', true)
  .order('created_at', { ascending: false })

One query — one SQL query with a JOIN. No N+1, no extra round-trips.

Realtime subscriptions

Realtime has three modes:

// 1. Postgres Changes — subscribe to table changes
const channel = supabase
  .channel('todos-changes')
  .on(
    'postgres_changes',
    {
      event: '*', // INSERT, UPDATE, DELETE, or *
      schema: 'public',
      table: 'todos',
      filter: `user_id=eq.${userId}`
    },
    (payload) => {
      console.log('Change:', payload.eventType, payload.new)
    }
  )
  .subscribe()

// 2. Broadcast — arbitrary messages between clients
const channel = supabase.channel('room-1')

// Sending
channel.send({
  type: 'broadcast',
  event: 'cursor-move',
  payload: { x: 100, y: 200, userId: 'abc' }
})

// Receiving
channel
  .on('broadcast', { event: 'cursor-move' }, (payload) => {
    console.log('Cursor:', payload.payload)
  })
  .subscribe()

// 3. Presence — tracking who's online
const channel = supabase.channel('online-users')
channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    console.log('Online:', Object.keys(state).length)
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({
        user_id: userId,
        online_at: new Date().toISOString()
      })
    }
  })

// Unsubscribe
supabase.removeChannel(channel)

Database Functions

Server-side logic right inside PostgreSQL. Faster than Edge Functions, no cold start:

-- Function to compute user stats
create or replace function get_user_stats(target_user_id uuid)
returns json
language plpgsql
security definer -- runs with the creator's privileges, not the caller's
as $$
declare
  result json;
begin
  select json_build_object(
    'total_todos', (select count(*) from todos where user_id = target_user_id),
    'completed', (select count(*) from todos where user_id = target_user_id and is_complete = true),
    'pending', (select count(*) from todos where user_id = target_user_id and is_complete = false)
  ) into result;

  return result;
end;
$$;

Calling it from the client:

const { data, error } = await supabase.rpc('get_user_stats', {
  target_user_id: userId
})
// { total_todos: 42, completed: 35, pending: 7 }

PostgreSQL has full-text search built in. No need for Elasticsearch:

-- Add a search column
alter table posts add column fts tsvector
  generated always as (
    setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('russian', coalesce(content, '')), 'B')
  ) stored;

-- Index for fast search
create index posts_fts_idx on posts using gin(fts);
// Search from the client
const { data } = await supabase
  .from('posts')
  .select('id, title, content')
  .textSearch('fts', 'supabase & startup', {
    type: 'websearch',
    config: 'russian'
  })

Edge Functions

What they are and when to use them

Edge Functions are serverless functions on the Deno runtime. TypeScript out of the box, no build config needed. Deploy via CLI.

Use Edge Functions for:

  • Webhook handlers (Stripe, GitHub, Telegram)
  • Integrations with external APIs (Claude, OpenAI, sending email)
  • Logic that doesn’t fit well in SQL (complex transformations, PDF generation)
  • Cron jobs

Don’t use them for:

  • CRUD operations — PostgREST is faster
  • Heavy computation — there are time and memory limits
  • Logic that’s naturally expressed in SQL — PostgreSQL functions are faster, no network round-trip

Creating and deploying

# Install the Supabase CLI
npm install -g supabase

# Initialize the project (if you haven't already)
supabase init

# Create a function
supabase functions new process-webhook

# Structure:
# supabase/functions/process-webhook/index.ts

Example: webhook handler

// supabase/functions/process-webhook/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { crypto } from 'https://deno.land/std/crypto/mod.ts'

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

// Verify the webhook signature (HMAC-SHA256, as used by Stripe and most providers)
async function verifySignature(body: string, signature: string, secret: string): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const signed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))
  const expected = Array.from(new Uint8Array(signed))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
  return signature === expected
}

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

  try {
    const body = await req.text()

    // Verify the webhook signature — reject requests without a valid signature
    const signature = req.headers.get('x-webhook-signature') ?? ''
    const webhookSecret = Deno.env.get('WEBHOOK_SECRET')!
    const isValid = await verifySignature(body, signature, webhookSecret)
    if (!isValid) {
      return new Response(JSON.stringify({ error: 'Invalid signature' }), {
        status: 401,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    }

    const payload = JSON.parse(body)

    // Basic payload validation
    if (!payload || typeof payload.type !== 'string') {
      return new Response(JSON.stringify({ error: 'Invalid payload' }), {
        status: 400,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
      })
    }

    // Create a Supabase client with the service_role key
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    )

    // Handle the webhook
    const { error } = await supabase
      .from('webhook_events')
      .insert({
        event_type: payload.type,
        data: payload,
        processed_at: new Date().toISOString()
      })

    if (error) throw error

    return new Response(
      JSON.stringify({ success: true }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : 'Internal error'
    return new Response(
      JSON.stringify({ error: message }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})

AI integration

// supabase/functions/ai-summarize/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

Deno.serve(async (req) => {
  const { text, postId } = await req.json()

  // Call the Claude API
  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!,
      'anthropic-version': '2023-06-01'
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-6',
      max_tokens: 300,
      messages: [{
        role: 'user',
        content: `Summarize this text in 2-3 sentences:\n\n${text}`
      }]
    })
  })

  const result = await response.json()
  const summary = result.content[0].text

  // Save to the database
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  await supabase
    .from('posts')
    .update({ summary })
    .eq('id', postId)

  return new Response(JSON.stringify({ summary }), {
    headers: { 'Content-Type': 'application/json' }
  })
})

Secrets management

# Set secrets (not committed to git)
supabase secrets set ANTHROPIC_API_KEY=sk-ant-...
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...

# View configured secrets
supabase secrets list

# Deploy the function
supabase functions deploy ai-summarize

SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are available automatically inside Edge Functions — no need to set them manually.

Storage

Uploading files

// Create a bucket (via Dashboard or SQL)
// Dashboard → Storage → New bucket

// Upload a file
const { data, error } = await supabase.storage
  .from('avatars')
  .upload(`${userId}/avatar.png`, file, {
    cacheControl: '3600',
    upsert: true // overwrite if it already exists
  })

// Get the public URL
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/avatar.png`)

// Download
const { data: fileData, error: downloadError } = await supabase.storage
  .from('avatars')
  .download(`${userId}/avatar.png`)

// Delete
const { error: deleteError } = await supabase.storage
  .from('avatars')
  .remove([`${userId}/avatar.png`])

// List files in a directory
const { data: files } = await supabase.storage
  .from('avatars')
  .list(userId, {
    limit: 100,
    offset: 0,
    sortBy: { column: 'created_at', order: 'desc' }
  })

Image transformations

Supabase transforms images on the fly via URL parameters:

const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/avatar.png`, {
    transform: {
      width: 200,
      height: 200,
      resize: 'cover', // cover, contain, fill
      quality: 80,
      format: 'origin' // or 'webp' for automatic conversion
    }
  })

The result is cached at the CDN. The original file is never modified.

RLS for Storage

Storage uses the same RLS policies as regular tables. Buckets live in the storage schema:

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

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

-- Users can only delete their own files
create policy "Users can delete own files"
  on storage.objects for delete
  using (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

AI features: pgvector and embeddings

Vector search in PostgreSQL

pgvector turns PostgreSQL into a vector database. No need for Pinecone, Weaviate, or Qdrant — embeddings live right next to the rest of your data.

-- Enable the extension
create extension if not exists vector;

-- Table with embeddings
create table documents (
  id bigint generated always as identity primary key,
  title text not null,
  content text not null,
  embedding vector(1536), -- dimension depends on the model
  metadata jsonb,
  created_at timestamptz default now()
);

-- Index for fast search (HNSW — the recommended default)
create index on documents using hnsw (embedding vector_cosine_ops)
  with (m = 16, ef_construction = 64);
-- Alternative under tight memory constraints — IVFFlat (build only after loading data):
-- create index on documents using ivfflat (embedding vector_cosine_ops)
--   with (lists = 100);

RAG with Supabase

RAG (Retrieval-Augmented Generation) is a pattern where an LLM receives relevant context from a knowledge base before generating its answer.

An Edge Function for generating embeddings and searching:

// supabase/functions/search/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

Deno.serve(async (req) => {
  const { query } = await req.json()

  // 1. Generate an embedding for the query
  const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: query
    })
  })

  const { data: [{ embedding }] } = await embeddingResponse.json()

  // 2. Search for similar documents
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  const { data: documents } = await supabase.rpc('match_documents', {
    query_embedding: embedding,
    match_threshold: 0.78,
    match_count: 5
  })

  return new Response(JSON.stringify({ documents }), {
    headers: { 'Content-Type': 'application/json' }
  })
})

The SQL function for similarity search:

create or replace function match_documents(
  query_embedding vector(1536),
  match_threshold float default 0.78,
  match_count int default 5
)
returns table (
  id bigint,
  title text,
  content text,
  similarity float
)
language sql stable
as $$
  select
    documents.id,
    documents.title,
    documents.content,
    1 - (documents.embedding <=> query_embedding) as similarity
  from documents
  where 1 - (documents.embedding <=> query_embedding) > match_threshold
  order by documents.embedding <=> query_embedding
  limit match_count;
$$;

The <=> operator is cosine distance. 1 - distance = similarity. The closer to 1, the more similar the documents.

HNSW vs IVFFlat. As of pgvector 0.6+, HNSW is the recommended index. Advantages: it builds immediately on table creation (IVFFlat needs data to train on), no need to rebuild as data grows, and better recall at comparable search speed. IVFFlat remains an option under tight memory constraints.

Hybrid search. For better accuracy, combine vector search with PostgreSQL full-text search via RRF (Reciprocal Rank Fusion). Supabase’s docs provide a ready-made pattern.

Example: a complete RAG pipeline

// Edge Function: question → context search → LLM answer
Deno.serve(async (req) => {
  const { question } = await req.json()

  // 1. Embed the question
  const embResponse = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: question
    })
  })
  const { data: [{ embedding }] } = await embResponse.json()

  // 2. Find relevant documents
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  const { data: docs } = await supabase.rpc('match_documents', {
    query_embedding: embedding,
    match_threshold: 0.75,
    match_count: 3
  })

  const context = docs.map(d => d.content).join('\n\n')

  // 3. Generate an answer using the context
  const chatResponse = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!,
      'anthropic-version': '2023-06-01'
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      system: `Answer questions using only the provided context. If the answer isn't in the context, say so.\n\nContext:\n${context}`,
      messages: [{ role: 'user', content: question }]
    })
  })

  const answer = await chatResponse.json()

  return new Response(JSON.stringify({
    answer: answer.content[0].text,
    sources: docs.map(d => ({ id: d.id, title: d.title, similarity: d.similarity }))
  }), {
    headers: { 'Content-Type': 'application/json' }
  })
})

Pricing and scaling

Plans (accurate as of June 2026)

FreeProTeamEnterprise
Price$0$25/mo$599/moOn request
Projects2UnlimitedUnlimitedUnlimited
Database500 MB8 GB8 GBCustom
Storage1 GB100 GB100 GBCustom
Egress (DB)5 GB250 GB250 GBCustom
Auth MAU50K100K100KCustom
Edge Functions500K invocations2M invocations2M invocationsCustom
Realtime200 connections500 connections500 connectionsCustom
BackupsNoneDaily (7 days)Daily (14 days) + PITRCustom
BranchingNoYes (preview + persistent)YesYes
Project pausingAfter 7 days inactiveNoNoNo
SOC2 / ISO 27001NoNoYesYes
SSO/SAMLNoNoYesYes
SLANoNo99.9%99.95%+
SupportCommunityEmailPriority emailDedicated

Pro and Team are usage-based. Anything above the included limits is billed separately. Example: on Pro, an extra 1 GB of database costs $0.125/GB/month, extra MAU costs $0.00325/MAU. Pro includes $10/month in compute credits, enough to cover a single Micro instance.

Self-hosted

Supabase is fully open source. You can run it on your own server via Docker:

# Clone the repo
git clone --depth 1 https://github.com/supabase/supabase
cd supabase/docker

# Copy the env file
cp .env.example .env
# Make sure to change: POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY

# Start it up
docker compose up -d

Self-hosted Supabase includes every component: PostgreSQL, GoTrue, PostgREST, Realtime, Storage, pg_meta, Kong, Edge Runtime (Deno). It doesn’t include: automatic backups or Supabase’s managed Dashboard (the open-source Studio is available instead).

When to move to Pro

  • The project needs to run 24/7 (Free pauses after 7 days without requests)
  • The database is approaching 500 MB
  • You need daily backups
  • You’re above 200 concurrent Realtime connections

Branching — preview environments for teams

Branching spins up a separate Supabase instance for every pull request: its own database, its own API credentials, its own Edge Functions. Production data doesn’t flow into the branch by default.

Two types of branches:

  • Preview branch — created automatically when a PR opens, deleted when it closes
  • Persistent branch — long-lived, for staging/QA/dev, doesn’t sleep from inactivity

Branching 2.0 (2025) dropped the mandatory GitHub tie-in — you can manage branches directly from the Dashboard (public alpha).

A startup workflow:

  1. main → the production project
  2. Feature branch → a preview branch with migrations applied automatically from the PR
  3. A seed file with no PII — test data with no production secrets
  4. Verify RLS, grants, and Edge Functions before merging

Branching is available starting on the Pro plan.

Cost optimization

  1. Indexes — without them, PostgreSQL scans the whole table. One well-placed index saves tens of dollars in compute
  2. Connection pooling — use Supavisor (built in). Don’t open direct connections from serverless
  3. RLS policies — a poorly written policy with a subquery slows down every query. Test it with EXPLAIN ANALYZE
  4. Edge Functions — cache results, don’t invoke on every request. Use PostgREST for CRUD
  5. Storage — enable caching (cacheControl), use transformations instead of storing multiple sizes

Best practices for startups

Migrations

Don’t edit the schema through the Dashboard in production. Use migrations:

# Create a migration
supabase migration new add_projects_table

# Edit: supabase/migrations/20260409_add_projects_table.sql
-- supabase/migrations/20260409_add_projects_table.sql
create table projects (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  owner_id uuid references auth.users(id) not null,
  created_at timestamptz default now()
);

-- Since May 2026: an explicit GRANT is required — without it, the table isn't visible via the Data API
grant select, insert, update, delete
  on table projects
  to authenticated;

alter table projects enable row level security;

create policy "Owner access"
  on projects for all
  using (auth.uid() = owner_id)
  with check (auth.uid() = owner_id);
# Apply the migration to the local database
supabase db push

# Apply it to production
supabase db push --linked

Migrations are committed to git. Every developer can see the schema’s change history. Rolling back means writing a reverse migration.

RLS from day one

Retrofitting RLS into a live application is painful. You have to check every table, every query. Enable RLS the moment you create a table.

Checklist:

  • grant select, insert, update, delete on table X to authenticated — an explicit GRANT (required since May 2026)
  • alter table X enable row level security on every new table
  • Separate policies for SELECT, INSERT, UPDATE, DELETE
  • with check for INSERT and UPDATE
  • Test: try to fetch someone else’s data through the Supabase client
  • service_role / secret key only in Edge Functions and server code

TypeScript types

The Supabase CLI generates types from your database schema:

supabase gen types typescript --linked > src/types/database.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from './types/database'

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

// TypeScript now knows the table structure
const { data } = await supabase
  .from('todos')
  .select('id, title, is_complete')
// data: { id: number; title: string; is_complete: boolean }[] | null

Run type generation in CI/CD after every migration.

Monitoring

  • Dashboard → Reports — slow queries, resource usage
  • pg_stat_statements — a PostgreSQL extension for monitoring query performance
  • Supabase Logs — logs from every service: Auth, PostgREST, Storage, Edge Functions
  • Alerts — set up notifications for exceeding limits (database > 80%, high CPU)
  • Log Drains (Pro+) — export logs to Datadog, Loki, Sentry, S3, or any OTLP-compatible collector. Ties Supabase into your existing observability stack
  • Supabase MCP — AI agents (Claude, Cursor, Windsurf, and others) can read the schema, run SQL, apply migrations, view logs, and deploy Edge Functions directly from your IDE. Don’t connect MCP to a production project with real data — use a separate dev project
-- Top 10 slowest queries
select
  query,
  calls,
  mean_exec_time::numeric(10,2) as avg_ms,
  total_exec_time::numeric(10,2) as total_ms
from pg_stat_statements
order by mean_exec_time desc
limit 10;

Backups

  • Free — no automatic backups. Run pg_dump manually
  • Pro — daily backups, kept for 7 days
  • Team — daily backups + PITR (Point-in-Time Recovery) — restore to any second
  • Self-hosted — set it up with pg_basebackup or WAL-G

For critical data on the Free plan:

# Backup via the CLI
supabase db dump -f backup.sql --linked

# Or directly via pg_dump
pg_dump postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres > backup.sql

Edge Functions vs PostgreSQL Functions

Edge FunctionsPostgreSQL Functions
RuntimeDeno (TypeScript)PL/pgSQL, PL/v8
Cold startYes (cut significantly in 2025, by roughly 97%)No
Network accessYes (fetch)Via pg_net
AI APINativeVia pg_net (more involved)
Speed for CRUDSlower (network hop)Faster (inside the DB)
DebuggingLogs in the DashboardRAISE NOTICE

The rule of thumb: if the logic works with data already in the database and doesn’t call an external API, use a PostgreSQL function. If it needs an outbound HTTP request (AI, payments, email), use an Edge Function.


Supabase covers 90% of a startup’s backend needs with a single tool. PostgreSQL under the hood is the advantage: full SQL, RLS, extensions, a proven technology. Start on the Free plan, move to Pro once the project reaches production — along with branching for safe deploys. The self-hosted option is always there as insurance against vendor lock-in.