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

> An exhaustive guide to Supabase: architecture, authentication, RLS, Edge Functions, pgvector, branching. Comparison with Firebase, pricing, and best practices for startups as of mid-2026.
> Author: Roman Belov · Published: 2026-06-26 · Source: https://futurecraft.pro/blog/supabase-startup-guide/

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](https://supabase.com/blog/supabase-series-f) 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

| Criterion | Supabase | Firebase |
|---|---|---|
| Database | PostgreSQL (relational, SQL) | Firestore (NoSQL, documents) |
| Queries | Full SQL, JOINs, window functions | Limited queries, no JOINs |
| Auth | GoTrue: email, OAuth, magic link, phone, SSO | Firebase Auth: email, OAuth, phone |
| Storage | S3-compatible, RLS at the bucket level | Cloud Storage for Firebase |
| Functions | Edge Functions (Deno/TypeScript) | Cloud Functions (Node.js) |
| Realtime | WebSocket subscriptions to DB changes | Built-in realtime sync |
| Vector/AI | pgvector — native vector search | None (needs Vertex AI separately) |
| Pricing | Predictable, tiered plans | Pay-per-read/write, hard to forecast |
| Open source | Yes, self-hostable via Docker | No |
| Vendor lock-in | Minimal (standard PostgreSQL) | High (proprietary database) |
| GraphQL | Built 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](https://github.com/orgs/supabase/discussions/41288) (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](https://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:

```sql
-- 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:

```bash
npm install @supabase/supabase-js
```

Initialize:

```typescript
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:

```typescript
// Sign up a user
const { data: authData, error: authError } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'secure-password-123'
})

// Sign in
const { data: signInData, error: loginError } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  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](https://supabase.com/changelog/46458-passkeys-for-supabase-auth-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:

```typescript
// 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

```typescript
// 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.

```sql
-- 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:

```sql
-- 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:

| Key | Replacement | Where to use it |
|---|---|---|
| `anon` key | `sb_publishable_*` | Public client code, browser, mobile app |
| `service_role` key | `sb_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:

```sql
-- 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](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically). 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 check`** — `using` 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

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```sql
-- 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:

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

### Full-text search

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

```sql
-- 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);
```

```typescript
// 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

```bash
# 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

```typescript
// 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

```typescript
// 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

```bash
# 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

```typescript
// 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:

```typescript
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:

```sql
-- 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.

```sql
-- 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:

```typescript
// 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:

```sql
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

```typescript
// 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)

| | Free | Pro | Team | Enterprise |
|---|---|---|---|---|
| Price | $0 | $25/mo | $599/mo | On request |
| Projects | 2 | Unlimited | Unlimited | Unlimited |
| Database | 500 MB | 8 GB | 8 GB | Custom |
| Storage | 1 GB | 100 GB | 100 GB | Custom |
| Egress (DB) | 5 GB | 250 GB | 250 GB | Custom |
| Auth MAU | 50K | 100K | 100K | Custom |
| Edge Functions | 500K invocations | 2M invocations | 2M invocations | Custom |
| Realtime | 200 connections | 500 connections | 500 connections | Custom |
| Backups | None | Daily (7 days) | Daily (14 days) + PITR | Custom |
| Branching | No | Yes (preview + persistent) | Yes | Yes |
| Project pausing | After 7 days inactive | No | No | No |
| SOC2 / ISO 27001 | No | No | Yes | Yes |
| SSO/SAML | No | No | Yes | Yes |
| SLA | No | No | 99.9% | 99.95%+ |
| Support | Community | Email | Priority email | Dedicated |

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:

```bash
# 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:

```bash
# Create a migration
supabase migration new add_projects_table

# Edit: supabase/migrations/20260409_add_projects_table.sql
```

```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);
```

```bash
# 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:

```bash
supabase gen types typescript --linked > src/types/database.ts
```

```typescript
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

```sql
-- 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:

```bash
# 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 Functions | PostgreSQL Functions |
|---|---|---|
| Runtime | Deno (TypeScript) | PL/pgSQL, PL/v8 |
| Cold start | Yes (cut significantly in 2025, by roughly 97%) | No |
| Network access | Yes (fetch) | Via pg_net |
| AI API | Native | Via pg_net (more involved) |
| Speed for CRUD | Slower (network hop) | Faster (inside the DB) |
| Debugging | Logs in the Dashboard | `RAISE 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.
