Claude Skill

api-baas-supabase

Supabase backend-as-a-service — Auth, Database, Realtime, Storage, Edge Functions, RLS policies, typed client

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-baas-supabase_skills_api-baas-supabase-3a51ef5.zip · 24 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-baas-supabase/skills/api-baas-supabase
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Supabase Patterns

Quick Guide: Use Supabase as your backend-as-a-service for Postgres database, authentication, realtime subscriptions, file storage, and edge functions. Always use the typed client with Database generic, enable RLS on every table, and use the secret key only on the server.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST enable Row Level Security (RLS) on EVERY table in an exposed schema — no exceptions)

(You MUST use the Database generic type with createClient<Database>() for type-safe queries)

(You MUST NEVER expose the secret key in client-side code — use the publishable key in browsers, the secret key only on the server)

(You MUST use (select auth.uid()) wrapped in a subquery inside RLS policies for performance)

(You MUST handle all Supabase responses with { data, error } destructuring — never assume success)

</critical_requirements>


Auto-detection: Supabase, createClient, @supabase/supabase-js, @supabase/ssr, supabase-js, auth.uid(), RLS, row level security, realtime, postgres_changes, supabase.auth, supabase.from, supabase.storage, supabase.functions, supabase.channel, edge function, Deno.serve

When to use:

  • Setting up a Supabase client with TypeScript type safety
  • Implementing authentication (email/password, OAuth, magic links, session management)
  • Querying Postgres via the Supabase client (select, insert, update, delete, RPC)
  • Writing Row Level Security policies for data access control
  • Subscribing to database changes in real time
  • Uploading and serving files from Supabase Storage
  • Building serverless functions with Supabase Edge Functions (Deno)

Key patterns covered:

  • Typed client setup with Database generic and environment variables
  • Auth flows: sign up, sign in, OAuth, magic link, session refresh, onAuthStateChange
  • Database queries with filters, joins, RPC calls, and error handling
  • RLS policies: USING vs WITH CHECK, auth.uid(), role-based access
  • Realtime subscriptions via channel().on('postgres_changes')
  • Storage: upload, signed URLs, public URLs, bucket policies
  • Edge Functions: Deno.serve, CORS headers, secrets, Supabase client in functions

When NOT to use:

  • Direct Postgres connections (use a database driver skill instead)
  • Complex server-side ORM patterns (use a dedicated ORM skill)
  • Non-Supabase authentication providers (use dedicated auth skills)

Detailed Resources:

  • For decision frameworks and anti-patterns, see reference.md

Client & Queries:

Authentication:

  • examples/auth.md — Full auth flows, OAuth, magic links, session refresh, middleware protection

Database:

Storage:

Edge Functions:




<decision_framework>

Decision Framework

Which Supabase Key to Use

Where is the code running?
├─ Browser / Client-side → publishable key (RLS enforced)
├─ Server / API route → publishable key + user JWT (RLS enforced per user)
└─ Admin / Migration script → secret key (bypasses RLS)
    └─ NEVER expose the secret key in client bundles

Auth Method Selection

What auth flow does the user need?
├─ Email + Password → signInWithPassword
├─ Social login (GitHub, Google, etc.) → signInWithOAuth
├─ Passwordless email → signInWithOtp (magic link)
├─ Phone + SMS → signInWithOtp (phone)
└─ SSO / SAML → signInWithSSO (enterprise)

Realtime vs Polling

How fresh must the data be?
├─ Instant (< 1 second) → Realtime subscription (postgres_changes)
├─ Near-instant (1-5 seconds) → Realtime subscription
├─ Periodic (> 5 seconds ok) → Polling with setInterval
└─ On-demand (user refresh) → Re-fetch on action
    └─ High-frequency updates (> 100/sec)?
        ├─ YES → Polling or batch (Realtime has per-subscriber checks)
        └─ NO → Realtime is fine

Storage: Public vs Private Buckets

Who should access the files?
├─ Anyone (public assets, avatars) → Public bucket + getPublicUrl()
├─ Authenticated users only → Private bucket + createSignedUrl()
├─ Specific users (own files) → Private bucket + RLS on storage.objects
└─ Server-only processing → secret key for upload/download

Edge Functions vs Client Queries

Does the operation need server-side logic?
├─ Simple CRUD → Client query with RLS (no edge function needed)
├─ Multi-step / transactional → Edge function or Postgres function (RPC)
├─ Third-party API call → Edge function
├─ Webhook receiver → Edge function
└─ Heavy computation → Edge function with EdgeRuntime.waitUntil() for background work

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Missing RLS on tables — Any table without RLS in an exposed schema is completely open to the public. In January 2025, 170+ apps were found with exposed databases due to missing RLS (CVE-2025-48757).
  • Secret key in client code — The secret key (formerly service_role key) bypasses all RLS. Exposing it in browser bundles gives every user full admin database access.
  • Ignoring { data, error } returns — Accessing data without checking error leads to runtime crashes when operations fail.
  • Using auth.jwt() ->> 'user_metadata' in RLS policies — user_metadata is modifiable by authenticated users via updateUser(). Never use it for access control decisions.

Medium Priority Issues:

  • Using FOR ALL in RLS policies — Separate into SELECT, INSERT, UPDATE, DELETE policies for clarity and auditability.
  • Bare auth.uid() in policies without subquery — Wrap in (select auth.uid()) for up to 94-99% performance improvement per Supabase benchmarks.
  • Not specifying to authenticated or to anon in policies — Without a role, policies apply to all roles, which may expose data unintentionally.
  • Using select("*") everywhere — Fetches all columns including sensitive data. Select only the columns you need.
  • Deprecated serve import in Edge Functions — import { serve } from "https://deno.land/std/http/server.ts" is deprecated. Use Deno.serve().

Common Mistakes:

  • Not adding .select() after .insert() or .update() — Without .select(), these methods return no data (only null).
  • Missing CORS headers in Edge Functions — Browser requests fail without proper CORS headers and OPTIONS handling.
  • Not unsubscribing from Realtime channels — Leaks WebSocket connections and can cause memory issues.
  • Using bare specifiers in Edge Functions — import { createClient } from "@supabase/supabase-js" fails in Deno. Use npm:@supabase/supabase-js@2.
  • Using getSession() to verify auth — getSession() reads from local storage and can be tampered with. Use getUser() for secure server-side verification.

Gotchas & Edge Cases:

  • Realtime DELETE events cannot be filtered — All deletes for a subscribed table are received regardless of filter.
  • Realtime requires replica identity full for old record data — By default, UPDATE and DELETE payloads only include the new record. Set alter table X replica identity full to access payload.old.
  • RLS policies are not applied to Realtime DELETE events — Be cautious about what information DELETE events expose.
  • onAuthStateChange fires on tab focus — SIGNED_IN events fire when a browser tab regains focus, not just on actual sign-in.
  • Do NOT call Supabase methods inside onAuthStateChange callback — This can cause deadlocks. Use setTimeout(..., 0) to defer.
  • Signed URLs expire — createSignedUrl() URLs expire after the specified duration. Signed upload URLs expire after 2 hours.
  • Public bucket URLs bypass RLS — Files in public buckets are accessible to anyone with the URL, regardless of policies.
  • Edge Function cold starts — First invocation after idle period has additional latency. Design "fat functions" (fewer, larger functions) to minimize cold starts.
  • Edge Functions: file writes only on /tmp — The /tmp directory is the only writable path in edge functions.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST enable Row Level Security (RLS) on EVERY table in an exposed schema — no exceptions)

(You MUST use the Database generic type with createClient<Database>() for type-safe queries)

(You MUST NEVER expose the secret key in client-side code — use the publishable key in browsers, the secret key only on the server)

(You MUST use (select auth.uid()) wrapped in a subquery inside RLS policies for performance)

(You MUST handle all Supabase responses with { data, error } destructuring — never assume success)

Failure to follow these rules will create security vulnerabilities, type-unsafe queries, and silent runtime failures.

</critical_reminders>

Files (skills)
  • examples
    • auth.md 9.3 KB
      # Supabase Auth Examples
      
      > Full auth flows, OAuth, magic links, session refresh, and middleware protection. See [SKILL.md](../SKILL.md) for core concepts.
      
      ---
      
      ## Pattern 1: Email/Password Authentication
      
      ### Good Example — Sign Up with Metadata
      
      ```typescript
      async function signUp(email: string, password: string, fullName: string) {
        const { data, error } = await supabase.auth.signUp({
          email,
          password,
          options: {
            data: {
              full_name: fullName,
            },
            emailRedirectTo: `${window.location.origin}/auth/callback`,
          },
        });
      
        if (error) {
          throw new Error(`Sign up failed: ${error.message}`);
        }
      
        // Note: data.user exists but may not be confirmed yet (check email)
        return data;
      }
      ```
      
      **Why good:** Passes user metadata at signup, `emailRedirectTo` for email confirmation callback, error checked before using data
      
      ### Good Example — Sign In
      
      ```typescript
      async function signIn(email: string, password: string) {
        const { data, error } = await supabase.auth.signInWithPassword({
          email,
          password,
        });
      
        if (error) {
          // Supabase intentionally returns ambiguous errors to prevent user enumeration
          throw new Error("Invalid email or password");
        }
      
        return data.session;
      }
      ```
      
      **Why good:** Generic error message prevents user enumeration (don't reveal whether email exists), returns session for immediate use
      
      ---
      
      ## Pattern 2: OAuth (Social Login)
      
      ### Good Example — GitHub OAuth
      
      ```typescript
      async function signInWithGitHub() {
        const { data, error } = await supabase.auth.signInWithOAuth({
          provider: "github",
          options: {
            redirectTo: `${window.location.origin}/auth/callback`,
            scopes: "read:user user:email",
          },
        });
      
        if (error) {
          throw new Error(`OAuth failed: ${error.message}`);
        }
      
        // Browser will redirect to GitHub — no further code runs
      }
      ```
      
      ### Good Example — Google OAuth
      
      ```typescript
      async function signInWithGoogle() {
        const { data, error } = await supabase.auth.signInWithOAuth({
          provider: "google",
          options: {
            redirectTo: `${window.location.origin}/auth/callback`,
            queryParams: {
              access_type: "offline",
              prompt: "consent",
            },
          },
        });
      
        if (error) {
          throw new Error(`OAuth failed: ${error.message}`);
        }
      }
      ```
      
      ### Good Example — OAuth Callback Handler
      
      ```typescript
      // /auth/callback route handler
      async function handleOAuthCallback() {
        const hashParams = new URLSearchParams(window.location.hash.substring(1));
        const accessToken = hashParams.get("access_token");
      
        if (!accessToken) {
          // Handle PKCE flow: exchange code for session
          const { data, error } = await supabase.auth.exchangeCodeForSession(
            new URLSearchParams(window.location.search).get("code") ?? "",
          );
      
          if (error) {
            throw new Error(`OAuth callback failed: ${error.message}`);
          }
        }
      
        // Session is now available via supabase.auth.getSession()
      }
      ```
      
      **Why good:** Handles both implicit and PKCE flows, `redirectTo` points to callback route, scopes request specific permissions, `queryParams` for provider-specific options
      
      ---
      
      ## Pattern 3: Magic Link (Passwordless)
      
      ### Good Example — Send and Verify Magic Link
      
      ```typescript
      // Send magic link email
      async function sendMagicLink(email: string) {
        const { error } = await supabase.auth.signInWithOtp({
          email,
          options: {
            emailRedirectTo: `${window.location.origin}/auth/callback`,
          },
        });
      
        if (error) {
          throw new Error(`Failed to send magic link: ${error.message}`);
        }
      
        // User receives email with login link
      }
      
      // Handle the callback (user clicks the link)
      // The session is automatically established when the user lands on the redirect URL
      // onAuthStateChange will fire with SIGNED_IN event
      ```
      
      **Why good:** Simple passwordless flow, redirect URL for after email click, error handling, session auto-established on callback
      
      ---
      
      ## Pattern 4: Auth State Listener
      
      ### Good Example — Global Auth State Management
      
      ```typescript
      // Register early in app lifecycle (e.g., app initialization)
      function setupAuthListener(callbacks: {
        onSignIn: (session: Session) => void;
        onSignOut: () => void;
      }) {
        const {
          data: { subscription },
        } = supabase.auth.onAuthStateChange((event, session) => {
          // IMPORTANT: Do NOT call Supabase methods directly in this callback
          // Use setTimeout to defer if needed
      
          switch (event) {
            case "SIGNED_IN":
              if (session) {
                setTimeout(() => callbacks.onSignIn(session), 0);
              }
              break;
            case "SIGNED_OUT":
              setTimeout(() => callbacks.onSignOut(), 0);
              break;
            case "TOKEN_REFRESHED":
              // Token refreshed automatically — session updated
              break;
            case "USER_UPDATED":
              // User profile was updated via updateUser()
              break;
            case "PASSWORD_RECOVERY":
              // User landed on password reset page
              break;
          }
        });
      
        // Return cleanup function
        return () => subscription.unsubscribe();
      }
      
      // Usage
      const cleanup = setupAuthListener({
        onSignIn: (session) => {
          // Navigate to dashboard, update UI state
        },
        onSignOut: () => {
          // Navigate to login, clear local state
        },
      });
      
      // On app unmount
      cleanup();
      ```
      
      **Why good:** Registered early in lifecycle, `setTimeout` prevents deadlocks, cleanup via unsubscribe, all events handled, callback pattern decouples auth from UI
      
      ### Bad Example — Calling Supabase Inside Listener
      
      ```typescript
      // BAD: Can cause deadlocks
      supabase.auth.onAuthStateChange(async (event, session) => {
        if (event === "SIGNED_IN") {
          // BAD: Calling Supabase inside the callback can deadlock
          const { data } = await supabase.from("profiles").select("*").single();
          // BAD: Using async callback
        }
      });
      ```
      
      **Why bad:** Calling Supabase methods inside the callback can cause deadlocks, async callbacks risk race conditions, no cleanup via unsubscribe
      
      ---
      
      ## Pattern 5: Session Management
      
      ### Good Example — Getting and Verifying the Current User
      
      ```typescript
      // Get the current session (reads from local storage — NOT secure for server-side)
      async function getSession() {
        const {
          data: { session },
          error,
        } = await supabase.auth.getSession();
      
        if (error) {
          throw new Error(`Failed to get session: ${error.message}`);
        }
      
        return session;
      }
      
      // SECURE: Verify the user server-side (makes API call to Supabase)
      async function getAuthenticatedUser() {
        const {
          data: { user },
          error,
        } = await supabase.auth.getUser();
      
        if (error || !user) {
          throw new Error("Not authenticated");
        }
      
        return user;
      }
      
      // Sign out
      async function signOut() {
        const { error } = await supabase.auth.signOut();
      
        if (error) {
          throw new Error(`Sign out failed: ${error.message}`);
        }
      }
      ```
      
      **Why good:** `getSession()` for quick client-side checks, `getUser()` for secure server-side verification, clear distinction between the two
      
      **When to use:** Use `getSession()` for UI state (is user logged in?). Use `getUser()` on the server to verify identity before performing sensitive operations. `getSession()` can be tampered with — it reads from local storage.
      
      ---
      
      ## Pattern 6: Middleware Auth Protection
      
      ### Good Example — Protecting Server Routes
      
      ```typescript
      // middleware/auth.ts
      import type { Database } from "../database.types";
      
      async function requireAuth(request: Request) {
        const authHeader = request.headers.get("Authorization");
      
        if (!authHeader) {
          return new Response(JSON.stringify({ error: "Missing authorization" }), {
            status: 401,
          });
        }
      
        const token = authHeader.replace("Bearer ", "");
      
        const supabase = createClient<Database>(
          SUPABASE_URL,
          SUPABASE_PUBLISHABLE_KEY,
          {
            global: { headers: { Authorization: `Bearer ${token}` } },
          },
        );
      
        // IMPORTANT: Use getUser() not getSession() for server-side verification
        const {
          data: { user },
          error,
        } = await supabase.auth.getUser();
      
        if (error || !user) {
          return new Response(JSON.stringify({ error: "Invalid token" }), {
            status: 401,
          });
        }
      
        return { user, supabase };
      }
      ```
      
      **Why good:** Extracts JWT from Authorization header, creates per-request client with user JWT, uses `getUser()` (not `getSession()`) for server-side verification, returns both user and scoped client
      
      ---
      
      ## Pattern 7: Password Reset
      
      ### Good Example — Request and Complete Password Reset
      
      ```typescript
      // Step 1: Request password reset email
      async function requestPasswordReset(email: string) {
        const { error } = await supabase.auth.resetPasswordForEmail(email, {
          redirectTo: `${window.location.origin}/auth/reset-password`,
        });
      
        if (error) {
          throw new Error(`Password reset request failed: ${error.message}`);
        }
      
        // Email sent — user clicks link and lands on redirectTo URL
      }
      
      // Step 2: Update password (after user lands on reset page)
      // The PASSWORD_RECOVERY event fires in onAuthStateChange
      async function updatePassword(newPassword: string) {
        const { data, error } = await supabase.auth.updateUser({
          password: newPassword,
        });
      
        if (error) {
          throw new Error(`Password update failed: ${error.message}`);
        }
      
        return data.user;
      }
      ```
      
      **Why good:** Two-step flow (request + update), `redirectTo` for password reset page, `onAuthStateChange` fires `PASSWORD_RECOVERY` event when user lands on reset page
      
      ---
      
      _For database query patterns, see [database.md](database.md). For storage patterns, see [storage.md](storage.md)._
      
    • core.md 7.2 KB
      # Supabase Core Examples
      
      > Client setup, typed queries, and error handling patterns. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Auth patterns:** See [auth.md](auth.md). **Database patterns:** See [database.md](database.md). **Storage patterns:** See [storage.md](storage.md). **Edge Functions:** See [edge-functions.md](edge-functions.md).
      
      ---
      
      ## Pattern 1: Client Setup — Browser
      
      ### Good Example — Typed Client with Database Generic
      
      ```typescript
      // lib/supabase.ts
      import { createClient } from "@supabase/supabase-js";
      import type { Database } from "./database.types";
      
      const SUPABASE_URL = process.env.SUPABASE_URL!;
      const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!;
      
      export const supabase = createClient<Database>(
        SUPABASE_URL,
        SUPABASE_PUBLISHABLE_KEY,
      );
      ```
      
      **Why good:** `Database` generic enables autocomplete for all table names, column names, and return types; publishable key is safe for browsers (RLS enforces access); named constants for environment variables
      
      ### Bad Example — Untyped Client with Hardcoded Credentials
      
      ```typescript
      import { createClient } from "@supabase/supabase-js";
      
      // BAD: No Database generic, hardcoded secrets
      const supabase = createClient(
        "https://abc.supabase.co",
        "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      );
      ```
      
      **Why bad:** No type safety on queries, hardcoded URL and key leak in source control, no way to switch between environments
      
      ---
      
      ## Pattern 2: Client Setup — Server-Side (SSR)
      
      ### Good Example — Server Client with User Context
      
      ```typescript
      // lib/supabase-server.ts
      import { createClient } from "@supabase/supabase-js";
      import type { Database } from "./database.types";
      
      const SUPABASE_URL = process.env.SUPABASE_URL!;
      const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY!;
      
      // Create a server-side client that respects RLS using the user's JWT
      export function createServerClient(accessToken: string) {
        return createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
          global: {
            headers: {
              Authorization: `Bearer ${accessToken}`,
            },
          },
        });
      }
      ```
      
      **Why good:** Passes user JWT for RLS enforcement on server, still uses publishable key (not secret key), factory function creates per-request clients
      
      ### Good Example — Admin Client (Bypasses RLS)
      
      ```typescript
      // lib/supabase-admin.ts
      import { createClient } from "@supabase/supabase-js";
      import type { Database } from "./database.types";
      
      const SUPABASE_URL = process.env.SUPABASE_URL!;
      const SUPABASE_SECRET_KEY = process.env.SUPABASE_SECRET_KEY!;
      
      // ONLY use on the server — bypasses all RLS policies
      export const supabaseAdmin = createClient<Database>(
        SUPABASE_URL,
        SUPABASE_SECRET_KEY,
      );
      ```
      
      **Why good:** Explicit naming (`supabaseAdmin`) signals this bypasses RLS, server-only secret key, typed with `Database` generic
      
      **When to use:** Admin operations, migrations, seeding data, webhook handlers that need full access. NEVER import this in client-side code.
      
      ---
      
      ## Pattern 3: Error Handling — Standard Pattern
      
      ### Good Example — Consistent Error Handling
      
      ```typescript
      import type { PostgrestError } from "@supabase/supabase-js";
      
      // Reusable error handler
      function handleSupabaseError(error: PostgrestError, context: string): never {
        throw new Error(`[Supabase] ${context}: ${error.message} (${error.code})`);
      }
      
      async function getPostById(postId: string) {
        const { data, error } = await supabase
          .from("posts")
          .select("id, title, content, created_at")
          .eq("id", postId)
          .single();
      
        if (error) {
          handleSupabaseError(error, `Failed to fetch post ${postId}`);
        }
      
        return data;
      }
      
      async function createPost(post: {
        title: string;
        content: string;
        author_id: string;
      }) {
        const { data, error } = await supabase
          .from("posts")
          .insert(post)
          .select()
          .single();
      
        if (error) {
          handleSupabaseError(error, "Failed to create post");
        }
      
        return data;
      }
      ```
      
      **Why good:** Consistent error handling pattern, error includes context and Supabase error code, `.single()` for expected single-row results, `.select()` after insert to return the created row
      
      ### Bad Example — Swallowing Errors
      
      ```typescript
      // BAD: Silent error handling
      async function getPost(id: string) {
        try {
          const { data } = await supabase
            .from("posts")
            .select("*")
            .eq("id", id)
            .single();
          return data;
        } catch {
          return null; // Error silently swallowed
        }
      }
      ```
      
      **Why bad:** Supabase errors are returned in `error` field (not thrown), try/catch doesn't help, `select("*")` fetches unnecessary columns, null return hides the actual problem
      
      ---
      
      ## Pattern 4: Type-Safe Query Results
      
      ### Good Example — Using Generated Types
      
      ```typescript
      import type { Database, Tables } from "./database.types";
      
      // Full row type
      type Post = Tables<"posts">;
      
      // Insert type (auto-generated columns like id, created_at are optional)
      type PostInsert = Database["public"]["Tables"]["posts"]["Insert"];
      
      // Update type (all columns optional)
      type PostUpdate = Database["public"]["Tables"]["posts"]["Update"];
      
      // Function with typed return
      async function getRecentPosts(): Promise<Post[]> {
        const PAGE_SIZE = 10;
      
        const { data, error } = await supabase
          .from("posts")
          .select("*")
          .order("created_at", { ascending: false })
          .limit(PAGE_SIZE);
      
        if (error) {
          throw new Error(`Failed to fetch recent posts: ${error.message}`);
        }
      
        return data;
      }
      
      // Typed insert
      async function createPost(post: PostInsert): Promise<Post> {
        const { data, error } = await supabase
          .from("posts")
          .insert(post)
          .select()
          .single();
      
        if (error) {
          throw new Error(`Failed to create post: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** Type aliases from generated types, `PostInsert` allows omitting auto-generated columns, return types are explicit, named constant for page size
      
      ---
      
      ## Pattern 5: Singleton vs Per-Request Client
      
      ### Good Example — When to Use Which
      
      ```typescript
      // SINGLETON (browser): One client for the entire app
      // lib/supabase.ts
      export const supabase = createClient<Database>(
        SUPABASE_URL,
        SUPABASE_PUBLISHABLE_KEY,
      );
      
      // PER-REQUEST (server): New client per request with user's token
      // api/route.ts
      export async function GET(request: Request) {
        const token = request.headers.get("Authorization")?.replace("Bearer ", "");
      
        if (!token) {
          return new Response("Unauthorized", { status: 401 });
        }
      
        const supabase = createClient<Database>(
          SUPABASE_URL,
          SUPABASE_PUBLISHABLE_KEY,
          {
            global: { headers: { Authorization: `Bearer ${token}` } },
          },
        );
      
        const { data, error } = await supabase.from("posts").select("id, title");
      
        if (error) {
          return new Response(JSON.stringify({ error: error.message }), {
            status: 500,
          });
        }
      
        return new Response(JSON.stringify(data));
      }
      ```
      
      **Why good:** Browser uses singleton (auth state shared), server creates per-request client with user JWT for RLS, proper authorization header extraction
      
      **When to use:** Singleton in browser apps. Per-request in API routes, server components, and Edge Functions where each request may have a different user.
      
      ---
      
      _For auth patterns, see [auth.md](auth.md). For database queries, see [database.md](database.md)._
      
    • database.md 12.2 KB
      # Supabase Database Examples
      
      > Complex queries, joins, RPC, migrations, and type generation. See [SKILL.md](../SKILL.md) for core concepts.
      
      ---
      
      ## Pattern 1: Select with Filters and Joins
      
      ### Good Example — Paginated Query with Foreign Key Join
      
      ```typescript
      const PAGE_SIZE = 20;
      
      interface PostWithAuthor {
        id: string;
        title: string;
        content: string;
        created_at: string;
        author: { username: string; avatar_url: string };
      }
      
      async function getPublishedPosts(page: number): Promise<PostWithAuthor[]> {
        const start = page * PAGE_SIZE;
        const end = start + PAGE_SIZE - 1;
      
        const { data, error } = await supabase
          .from("posts")
          .select(
            "id, title, content, created_at, author:profiles(username, avatar_url)",
          )
          .eq("published", true)
          .order("created_at", { ascending: false })
          .range(start, end);
      
        if (error) {
          throw new Error(`Failed to fetch posts: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** Named constant for page size, join syntax via foreign key reference (`author:profiles(...)`), only selected columns (not `*`), range-based pagination, ordered results
      
      ### Good Example — Multiple Filters
      
      ```typescript
      async function searchPosts(filters: {
        query?: string;
        category?: string;
        authorId?: string;
        minDate?: string;
      }) {
        let query = supabase
          .from("posts")
          .select("id, title, content, created_at, category")
          .eq("published", true)
          .order("created_at", { ascending: false });
      
        if (filters.query) {
          query = query.ilike("title", `%${filters.query}%`);
        }
      
        if (filters.category) {
          query = query.eq("category", filters.category);
        }
      
        if (filters.authorId) {
          query = query.eq("author_id", filters.authorId);
        }
      
        if (filters.minDate) {
          query = query.gte("created_at", filters.minDate);
        }
      
        const { data, error } = await query;
      
        if (error) {
          throw new Error(`Search failed: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** Composable query builder (conditionally chains filters), `ilike` for case-insensitive search, `gte` for date filtering, typed filter object
      
      ---
      
      ## Pattern 2: Insert, Update, Delete
      
      ### Good Example — Insert with Return Data
      
      ```typescript
      import type { Database } from "./database.types";
      
      type PostInsert = Database["public"]["Tables"]["posts"]["Insert"];
      
      async function createPost(post: PostInsert) {
        const { data, error } = await supabase
          .from("posts")
          .insert(post)
          .select("id, title, created_at")
          .single();
      
        if (error) {
          throw new Error(`Failed to create post: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      ### Good Example — Upsert (Insert or Update)
      
      ```typescript
      async function upsertUserPreferences(
        userId: string,
        preferences: Record<string, unknown>,
      ) {
        const { data, error } = await supabase
          .from("user_preferences")
          .upsert(
            { user_id: userId, preferences, updated_at: new Date().toISOString() },
            { onConflict: "user_id" },
          )
          .select()
          .single();
      
        if (error) {
          throw new Error(`Failed to save preferences: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      ### Good Example — Update with Conditions
      
      ```typescript
      async function publishPost(postId: string, userId: string) {
        const { data, error } = await supabase
          .from("posts")
          .update({ published: true, published_at: new Date().toISOString() })
          .eq("id", postId)
          .eq("author_id", userId) // Ensure user owns the post
          .select()
          .single();
      
        if (error) {
          throw new Error(`Failed to publish post: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      ### Good Example — Delete
      
      ```typescript
      async function deletePost(postId: string) {
        const { error } = await supabase.from("posts").delete().eq("id", postId);
      
        if (error) {
          throw new Error(`Failed to delete post: ${error.message}`);
        }
      }
      ```
      
      **Why good:** `.select()` after insert/upsert/update returns the affected row, `.single()` for single-row operations, upsert with `onConflict` for idempotent writes, multiple `.eq()` filters for ownership checks
      
      ### Bad Example — Missing .select() After Insert
      
      ```typescript
      // BAD: Insert without .select() returns null data
      const { data } = await supabase.from("posts").insert({ title: "Hello" });
      console.log(data); // null — no data returned!
      ```
      
      **Why bad:** Without `.select()`, insert/update return `null` for `data`, common source of confusion
      
      ---
      
      ## Pattern 3: RPC (Remote Procedure Calls)
      
      ### Good Example — Calling Postgres Functions
      
      ```sql
      -- migration: create the function first
      create or replace function search_posts(
        search_query text,
        result_limit int default 10
      )
      returns setof posts
      language sql
      security definer
      set search_path = public
      as $$
        select *
        from posts
        where
          published = true
          and (
            title ilike '%' || search_query || '%'
            or content ilike '%' || search_query || '%'
          )
        order by created_at desc
        limit result_limit;
      $$;
      ```
      
      ```typescript
      const DEFAULT_SEARCH_LIMIT = 10;
      
      async function searchPosts(query: string, limit = DEFAULT_SEARCH_LIMIT) {
        const { data, error } = await supabase.rpc("search_posts", {
          search_query: query,
          result_limit: limit,
        });
      
        if (error) {
          throw new Error(`Search RPC failed: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** `security definer` runs with function owner's permissions (useful for bypassing RLS in controlled ways), `set search_path` prevents search path attacks, named constant for default limit, typed RPC parameters via `Database` generic
      
      **When to use:** Complex queries that benefit from Postgres execution (full-text search, aggregations, multi-step operations), queries requiring elevated permissions via `security definer`, or operations that need transactional guarantees.
      
      ---
      
      ## Pattern 4: Counting Rows
      
      ### Good Example — Count with Filters
      
      ```typescript
      async function countPublishedPosts(authorId: string) {
        const { count, error } = await supabase
          .from("posts")
          .select("*", { count: "exact", head: true })
          .eq("author_id", authorId)
          .eq("published", true);
      
        if (error) {
          throw new Error(`Count failed: ${error.message}`);
        }
      
        return count ?? 0;
      }
      ```
      
      **Why good:** `{ count: "exact", head: true }` returns only the count (no row data transferred), filters still apply, efficient for pagination totals
      
      ---
      
      ## Pattern 5: RLS Policy Patterns
      
      ### Good Example — User-Owned Data
      
      ```sql
      -- Enable RLS
      alter table public.posts enable row level security;
      
      -- SELECT: Users see their own posts or published posts
      create policy "posts_select_policy"
      on public.posts for select
      to authenticated, anon
      using (
        published = true
        or (select auth.uid()) = author_id
      );
      
      -- INSERT: Users can only create posts as themselves
      create policy "posts_insert_policy"
      on public.posts for insert
      to authenticated
      with check (
        (select auth.uid()) = author_id
      );
      
      -- UPDATE: Users can only update their own posts
      create policy "posts_update_policy"
      on public.posts for update
      to authenticated
      using ( (select auth.uid()) = author_id )
      with check ( (select auth.uid()) = author_id );
      
      -- DELETE: Users can only delete their own posts
      create policy "posts_delete_policy"
      on public.posts for delete
      to authenticated
      using ( (select auth.uid()) = author_id );
      ```
      
      ### Good Example — Team-Based Access with Helper Function
      
      ```sql
      -- Helper function for checking team membership
      create or replace function is_team_member(team_id uuid)
      returns boolean
      language sql
      security definer
      set search_path = public
      as $$
        select exists (
          select 1 from team_members
          where team_members.team_id = is_team_member.team_id
          and team_members.user_id = (select auth.uid())
        );
      $$;
      
      -- Team documents policy
      alter table public.documents enable row level security;
      
      create policy "documents_select_team"
      on public.documents for select
      to authenticated
      using ( is_team_member(team_id) );
      
      create policy "documents_insert_team"
      on public.documents for insert
      to authenticated
      with check ( is_team_member(team_id) );
      ```
      
      **Why good:** Separate policies per operation, `(select auth.uid())` in subquery for performance, helper function for complex access checks, `security definer` on helper function, `set search_path` prevents attacks, roles specified with `to`
      
      ### Bad Example — Insecure RLS Policies
      
      ```sql
      -- BAD: FOR ALL is less auditable
      create policy "bad_policy" on public.posts for all
      using (auth.uid() = author_id); -- No subquery wrapper
      
      -- BAD: Trusting user-modifiable metadata
      create policy "admin_check" on public.posts for select
      using (
        auth.jwt() -> 'user_metadata' ->> 'role' = 'admin'
        -- user_metadata can be modified by users via updateUser()!
      );
      
      -- BAD: Missing role specification
      create policy "too_open" on public.posts for select
      using (true); -- Applies to ALL roles including anon!
      ```
      
      **Why bad:** `FOR ALL` mixes read/write logic, bare `auth.uid()` hurts performance, `user_metadata` is user-modifiable (insecure for access control), missing `to` clause applies to all roles
      
      ---
      
      ## Pattern 6: Type Generation Workflow
      
      ### Good Example — CLI Type Generation
      
      ```bash
      # Initial setup
      npm i supabase --save-dev
      npx supabase login
      npx supabase init
      
      # Generate types from remote project
      npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > src/lib/database.types.ts
      
      # Generate types from local dev database
      npx supabase gen types typescript --local > src/lib/database.types.ts
      
      # Add to package.json scripts
      # "gen:types": "supabase gen types typescript --local > src/lib/database.types.ts"
      ```
      
      ### Good Example — Using Generated Type Helpers
      
      ```typescript
      import type { Database, Tables, Enums } from "./database.types";
      
      // Row type (what you get back from SELECT)
      type Post = Tables<"posts">;
      
      // Insert type (omits auto-generated columns like id, created_at)
      type PostInsert = Database["public"]["Tables"]["posts"]["Insert"];
      
      // Update type (all columns optional)
      type PostUpdate = Database["public"]["Tables"]["posts"]["Update"];
      
      // Enum type
      type PostStatus = Enums<"post_status">;
      
      // Use in function signatures
      async function updatePost(id: string, updates: PostUpdate): Promise<Post> {
        const { data, error } = await supabase
          .from("posts")
          .update(updates)
          .eq("id", id)
          .select()
          .single();
      
        if (error) {
          throw new Error(`Update failed: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** Generated types give full autocomplete and type checking, `Tables<"posts">` shortcut avoids deep path access, separate Insert/Update types handle optional columns correctly, regenerate after schema changes
      
      ---
      
      ## Pattern 7: Migration Best Practices
      
      ### Good Example — Migration with RLS
      
      ```sql
      -- supabase/migrations/20240315_create_posts.sql
      
      -- Create table
      create table if not exists public.posts (
        id uuid default gen_random_uuid() primary key,
        author_id uuid references auth.users(id) on delete cascade not null,
        title text not null,
        content text not null,
        published boolean default false not null,
        published_at timestamptz,
        created_at timestamptz default now() not null,
        updated_at timestamptz default now() not null
      );
      
      -- Enable RLS immediately
      alter table public.posts enable row level security;
      
      -- Create policies
      create policy "posts_select"
      on public.posts for select
      to authenticated, anon
      using (
        published = true
        or (select auth.uid()) = author_id
      );
      
      create policy "posts_insert"
      on public.posts for insert
      to authenticated
      with check ( (select auth.uid()) = author_id );
      
      create policy "posts_update"
      on public.posts for update
      to authenticated
      using ( (select auth.uid()) = author_id )
      with check ( (select auth.uid()) = author_id );
      
      create policy "posts_delete"
      on public.posts for delete
      to authenticated
      using ( (select auth.uid()) = author_id );
      
      -- Add indexes for columns used in policies
      create index if not exists idx_posts_author_id on public.posts(author_id);
      create index if not exists idx_posts_published on public.posts(published);
      create index if not exists idx_posts_created_at on public.posts(created_at desc);
      ```
      
      **Why good:** RLS enabled in the same migration as table creation (never forgotten), indexes on columns used in policies and queries, `on delete cascade` for referential integrity, `gen_random_uuid()` for primary key generation
      
      ---
      
      _For auth patterns, see [auth.md](auth.md). For storage patterns, see [storage.md](storage.md). For edge functions, see [edge-functions.md](edge-functions.md)._
      
    • edge-functions.md 12.4 KB
      # Supabase Edge Functions Examples
      
      > Deno edge functions, `Deno.serve()`, CORS handling, secrets, and Supabase client usage. See [SKILL.md](../SKILL.md) for core concepts.
      
      ---
      
      ## Pattern 1: Basic Edge Function
      
      ### Good Example — Hello World with CORS
      
      ```typescript
      // supabase/functions/hello-world/index.ts
      
      const corsHeaders = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers":
          "authorization, x-client-info, apikey, content-type",
      };
      
      Deno.serve(async (req) => {
        // Handle CORS preflight
        if (req.method === "OPTIONS") {
          return new Response("ok", { headers: corsHeaders });
        }
      
        try {
          const { name } = await req.json();
      
          return new Response(JSON.stringify({ message: `Hello ${name}!` }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
            status: 200,
          });
        } catch (err) {
          return new Response(JSON.stringify({ error: err.message }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
            status: 400,
          });
        }
      });
      ```
      
      **Why good:** `Deno.serve` (not deprecated `serve` import), CORS headers on every response (including errors), OPTIONS preflight handled, JSON parsing with try/catch, proper Content-Type header
      
      ### Bad Example — Deprecated serve Import
      
      ```typescript
      // BAD: Deprecated import
      import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
      
      serve(async (req) => {
        // ...
      });
      ```
      
      **Why bad:** `serve` from deno.land/std is deprecated, `Deno.serve` is the built-in replacement, pinned deno.land URLs break when versions are removed
      
      ---
      
      ## Pattern 2: Edge Function with Supabase Client
      
      ### Good Example — Authenticated Database Access
      
      ```typescript
      // supabase/functions/get-user-posts/index.ts
      import { createClient } from "npm:@supabase/supabase-js@2";
      
      const corsHeaders = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers":
          "authorization, x-client-info, apikey, content-type",
      };
      
      Deno.serve(async (req) => {
        if (req.method === "OPTIONS") {
          return new Response("ok", { headers: corsHeaders });
        }
      
        try {
          // Create client with user's JWT — RLS enforced
          const supabase = createClient(
            Deno.env.get("SUPABASE_URL")!,
            Deno.env.get("SUPABASE_ANON_KEY")!,
            {
              global: {
                headers: { Authorization: req.headers.get("Authorization")! },
              },
            },
          );
      
          // This query is filtered by RLS using the user's JWT
          const { data, error } = await supabase
            .from("posts")
            .select("id, title, created_at")
            .order("created_at", { ascending: false });
      
          if (error) {
            throw error;
          }
      
          return new Response(JSON.stringify({ posts: data }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
          });
        } catch (err) {
          return new Response(JSON.stringify({ error: err.message }), {
            headers: { ...corsHeaders, "Content-Type": "application/json" },
            status: 400,
          });
        }
      });
      ```
      
      **Why good:** `npm:` prefix for Deno package resolution, user JWT forwarded for RLS, `Deno.env.get()` for secrets (auto-injected by Supabase), error from Supabase re-thrown into catch block
      
      ### Good Example — Admin Access (Bypasses RLS)
      
      ```typescript
      // supabase/functions/admin-cleanup/index.ts
      import { createClient } from "npm:@supabase/supabase-js@2";
      
      Deno.serve(async (req) => {
        // Verify this is an internal/admin call
        const authHeader = req.headers.get("Authorization");
        if (authHeader !== `Bearer ${Deno.env.get("ADMIN_SECRET")}`) {
          return new Response("Unauthorized", { status: 401 });
        }
      
        // Admin client — bypasses all RLS
        const supabaseAdmin = createClient(
          Deno.env.get("SUPABASE_URL")!,
          Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
        );
      
        const STALE_DAYS = 30;
        const cutoffDate = new Date();
        cutoffDate.setDate(cutoffDate.getDate() - STALE_DAYS);
      
        const { error } = await supabaseAdmin
          .from("temp_files")
          .delete()
          .lt("created_at", cutoffDate.toISOString());
      
        if (error) {
          return new Response(JSON.stringify({ error: error.message }), {
            status: 500,
          });
        }
      
        return new Response(JSON.stringify({ success: true }));
      });
      ```
      
      **Why good:** Custom auth check for admin endpoints, `service_role` key for RLS bypass, named constant for stale threshold, used only in server-to-server calls
      
      ---
      
      ## Pattern 3: Shared Utilities
      
      ### Good Example — Reusable CORS and Client Setup
      
      ```typescript
      // supabase/functions/_shared/cors.ts
      export const corsHeaders = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers":
          "authorization, x-client-info, apikey, content-type",
      };
      
      export function corsResponse() {
        return new Response("ok", { headers: corsHeaders });
      }
      
      export function jsonResponse(data: unknown, status = 200): Response {
        return new Response(JSON.stringify(data), {
          headers: { ...corsHeaders, "Content-Type": "application/json" },
          status,
        });
      }
      
      export function errorResponse(message: string, status = 400): Response {
        return new Response(JSON.stringify({ error: message }), {
          headers: { ...corsHeaders, "Content-Type": "application/json" },
          status,
        });
      }
      ```
      
      ```typescript
      // supabase/functions/_shared/supabase.ts
      import { createClient } from "npm:@supabase/supabase-js@2";
      
      export function createUserClient(req: Request) {
        return createClient(
          Deno.env.get("SUPABASE_URL")!,
          Deno.env.get("SUPABASE_ANON_KEY")!,
          {
            global: {
              headers: { Authorization: req.headers.get("Authorization")! },
            },
          },
        );
      }
      
      export function createAdminClient() {
        return createClient(
          Deno.env.get("SUPABASE_URL")!,
          Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
        );
      }
      ```
      
      ```typescript
      // supabase/functions/my-function/index.ts
      import { corsResponse, jsonResponse, errorResponse } from "../_shared/cors.ts";
      import { createUserClient } from "../_shared/supabase.ts";
      
      Deno.serve(async (req) => {
        if (req.method === "OPTIONS") {
          return corsResponse();
        }
      
        try {
          const supabase = createUserClient(req);
          const { data, error } = await supabase.from("posts").select("id, title");
      
          if (error) throw error;
      
          return jsonResponse({ posts: data });
        } catch (err) {
          return errorResponse(err.message);
        }
      });
      ```
      
      **Why good:** `_shared/` folder for reusable code, relative imports (`../`), separate user vs admin client factories, consistent response helpers, DRY CORS handling
      
      ---
      
      ## Pattern 4: Webhook Handler
      
      ### Good Example — Third-Party Webhook
      
      ```typescript
      // supabase/functions/handle-webhook/index.ts
      import { createAdminClient } from "../_shared/supabase.ts";
      import { jsonResponse, errorResponse } from "../_shared/cors.ts";
      import { createHmac } from "node:crypto";
      
      const WEBHOOK_SECRET = Deno.env.get("WEBHOOK_SECRET")!;
      
      function verifySignature(body: string, signature: string): boolean {
        const expected = createHmac("sha256", WEBHOOK_SECRET)
          .update(body)
          .digest("hex");
        return expected === signature;
      }
      
      Deno.serve(async (req) => {
        const signature = req.headers.get("x-webhook-signature");
      
        if (!signature) {
          return errorResponse("Missing webhook signature", 400);
        }
      
        try {
          const body = await req.text();
      
          if (!verifySignature(body, signature)) {
            return errorResponse("Invalid signature", 401);
          }
      
          const event = JSON.parse(body);
          const supabase = createAdminClient();
      
          switch (event.type) {
            case "order.completed": {
              const { error } = await supabase
                .from("orders")
                .update({ status: "paid" })
                .eq("external_id", event.data.id);
      
              if (error) throw error;
              break;
            }
      
            case "subscription.cancelled": {
              const { error } = await supabase
                .from("subscriptions")
                .update({ status: "cancelled" })
                .eq("external_id", event.data.id);
      
              if (error) throw error;
              break;
            }
          }
      
          return jsonResponse({ received: true });
        } catch (err) {
          return errorResponse(err.message, 400);
        }
      });
      ```
      
      **Why good:** Webhook signature verification for security, admin client for webhooks (no user JWT available), switch on event type, named constant for webhook secret, `node:crypto` for HMAC verification
      
      ---
      
      ## Pattern 5: Multi-Route Edge Function ("Fat Function")
      
      ### Good Example — URL-Based Routing
      
      ```typescript
      // supabase/functions/api/index.ts
      import { createUserClient } from "../_shared/supabase.ts";
      import { corsResponse, jsonResponse, errorResponse } from "../_shared/cors.ts";
      
      Deno.serve(async (req) => {
        if (req.method === "OPTIONS") {
          return corsResponse();
        }
      
        const url = new URL(req.url);
        const path = url.pathname.replace(/^\/api/, "");
        const supabase = createUserClient(req);
      
        try {
          // Route: GET /posts
          if (req.method === "GET" && path === "/posts") {
            const { data, error } = await supabase
              .from("posts")
              .select("id, title")
              .eq("published", true);
      
            if (error) throw error;
            return jsonResponse({ posts: data });
          }
      
          // Route: POST /posts
          if (req.method === "POST" && path === "/posts") {
            const body = await req.json();
            const { data, error } = await supabase
              .from("posts")
              .insert(body)
              .select()
              .single();
      
            if (error) throw error;
            return jsonResponse({ post: data }, 201);
          }
      
          return errorResponse("Not found", 404);
        } catch (err) {
          return errorResponse(err.message, 400);
        }
      });
      ```
      
      **Why good:** Single "fat function" reduces cold starts, URL-based routing with standard Web APIs, per-request user client for RLS, shared error handling. For complex routing, use a lightweight router library via `npm:` imports.
      
      **When to use:** Multiple related endpoints that share logic. "Fat functions" with fewer, larger functions minimize cold starts compared to many small functions.
      
      ---
      
      ## Pattern 6: Background Processing
      
      ### Good Example — Fire and Forget with waitUntil
      
      ```typescript
      // supabase/functions/process-upload/index.ts
      import { createAdminClient } from "../_shared/supabase.ts";
      import { jsonResponse, corsResponse, errorResponse } from "../_shared/cors.ts";
      
      Deno.serve(async (req) => {
        if (req.method === "OPTIONS") {
          return corsResponse();
        }
      
        try {
          const { fileId } = await req.json();
      
          // Start background processing — does NOT block the response
          EdgeRuntime.waitUntil(processFileInBackground(fileId));
      
          // Return immediately
          return jsonResponse({ status: "processing", fileId });
        } catch (err) {
          return errorResponse(err.message);
        }
      });
      
      async function processFileInBackground(fileId: string) {
        const supabase = createAdminClient();
      
        // Update status
        await supabase
          .from("files")
          .update({ status: "processing" })
          .eq("id", fileId);
      
        // Do heavy work (image resize, PDF generation, etc.)
        // ...
      
        // Update status when done
        await supabase.from("files").update({ status: "completed" }).eq("id", fileId);
      }
      ```
      
      **Why good:** `EdgeRuntime.waitUntil()` runs work after response is sent, immediate response to client, status tracking in database, heavy work doesn't block the HTTP response
      
      **When to use:** Image processing, PDF generation, sending emails, any work that takes longer than the user should wait for. The client gets an immediate response and can poll for status.
      
      ---
      
      ## Pattern 7: Accessing Secrets
      
      ### Good Example — Environment Variables
      
      ```typescript
      // Secrets set via CLI: npx supabase secrets set MY_API_KEY=abc123
      
      Deno.serve(async (req) => {
        // Auto-injected by Supabase (new projects use SUPABASE_PUBLISHABLE_KEY / SUPABASE_SECRET_KEY)
        const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
        const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
        const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
      
        // Custom secrets
        const apiKey = Deno.env.get("MY_API_KEY")!;
        const webhookSecret = Deno.env.get("WEBHOOK_SECRET")!;
      
        // Use secrets...
        return new Response("ok");
      });
      ```
      
      **Why good:** `Deno.env.get()` for all secrets, Supabase auto-injects project URL and keys, custom secrets set via CLI, never hardcode secrets
      
      ### Bad Example — Hardcoded Secrets
      
      ```typescript
      // BAD: Hardcoded secrets
      const API_KEY = "sk_live_abc123"; // Exposed in source code!
      const SUPABASE_URL = "https://abc.supabase.co"; // Should be env var
      ```
      
      **Why bad:** Secrets in source code are exposed in version control, cannot rotate without redeploying, environment variables are the correct approach
      
      ---
      
      _For auth patterns, see [auth.md](auth.md). For database queries, see [database.md](database.md). For storage patterns, see [storage.md](storage.md)._
      
    • storage.md 8.4 KB
      # Supabase Storage Examples
      
      > File upload, signed URLs, bucket policies, and image transforms. See [SKILL.md](../SKILL.md) for core concepts.
      
      ---
      
      ## Pattern 1: File Upload
      
      ### Good Example — Upload with Error Handling
      
      ```typescript
      const BUCKET_NAME = "documents";
      const MAX_FILE_SIZE_MB = 10;
      const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
      
      async function uploadFile(
        userId: string,
        file: File,
        folder = "uploads",
      ): Promise<string> {
        if (file.size > MAX_FILE_SIZE_BYTES) {
          throw new Error(`File exceeds ${MAX_FILE_SIZE_MB}MB limit`);
        }
      
        const fileExtension = file.name.split(".").pop();
        const filePath = `${userId}/${folder}/${crypto.randomUUID()}.${fileExtension}`;
      
        const { data, error } = await supabase.storage
          .from(BUCKET_NAME)
          .upload(filePath, file, {
            cacheControl: "3600",
            upsert: false,
          });
      
        if (error) {
          throw new Error(`Upload failed: ${error.message}`);
        }
      
        return data.path;
      }
      ```
      
      **Why good:** Named constants for bucket and size limits, unique filename prevents collisions, user-scoped path, file size validation before upload, `upsert: false` prevents accidental overwrites
      
      ### Good Example — Upload with Upsert (Avatar)
      
      ```typescript
      const AVATAR_BUCKET = "avatars";
      
      async function uploadAvatar(userId: string, file: File): Promise<string> {
        const filePath = `${userId}/avatar.png`;
      
        const { data, error } = await supabase.storage
          .from(AVATAR_BUCKET)
          .upload(filePath, file, {
            cacheControl: "3600",
            contentType: file.type,
            upsert: true, // Replace existing avatar
          });
      
        if (error) {
          throw new Error(`Avatar upload failed: ${error.message}`);
        }
      
        // Return public URL for display
        const {
          data: { publicUrl },
        } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(filePath);
      
        return publicUrl;
      }
      ```
      
      **Why good:** `upsert: true` replaces existing avatar (one avatar per user), explicit `contentType`, returns public URL for immediate display, deterministic path (`userId/avatar.png`)
      
      ---
      
      ## Pattern 2: Signed URLs (Private Files)
      
      ### Good Example — Time-Limited Access
      
      ```typescript
      const SIGNED_URL_EXPIRY_SECONDS = 3600; // 1 hour
      
      async function getPrivateFileUrl(filePath: string): Promise<string> {
        const { data, error } = await supabase.storage
          .from("private-documents")
          .createSignedUrl(filePath, SIGNED_URL_EXPIRY_SECONDS);
      
        if (error) {
          throw new Error(`Failed to create signed URL: ${error.message}`);
        }
      
        return data.signedUrl;
      }
      
      // Batch signed URLs for multiple files
      async function getMultipleSignedUrls(filePaths: string[]): Promise<string[]> {
        const { data, error } = await supabase.storage
          .from("private-documents")
          .createSignedUrls(filePaths, SIGNED_URL_EXPIRY_SECONDS);
      
        if (error) {
          throw new Error(`Failed to create signed URLs: ${error.message}`);
        }
      
        return data.map((item) => item.signedUrl);
      }
      ```
      
      **Why good:** Named constant for expiry duration, batch method for multiple files (single API call), error handling
      
      **When to use:** Private files that need temporary access (document downloads, invoice PDFs, private images). Signed URLs expire — do not cache them longer than their expiry.
      
      ---
      
      ## Pattern 3: Public URLs (Public Buckets)
      
      ### Good Example — Public File Access
      
      ```typescript
      const PUBLIC_BUCKET = "public-assets";
      
      function getPublicFileUrl(filePath: string): string {
        const {
          data: { publicUrl },
        } = supabase.storage.from(PUBLIC_BUCKET).getPublicUrl(filePath);
      
        return publicUrl;
      }
      
      // With image transforms (for image files in public buckets)
      function getResizedImageUrl(
        filePath: string,
        width: number,
        height: number,
      ): string {
        const {
          data: { publicUrl },
        } = supabase.storage.from(PUBLIC_BUCKET).getPublicUrl(filePath, {
          transform: {
            width,
            height,
            resize: "contain",
          },
        });
      
        return publicUrl;
      }
      ```
      
      **Why good:** `getPublicUrl` is synchronous (no `await`), image transforms resize server-side (saves bandwidth), `resize: "contain"` preserves aspect ratio
      
      **When to use:** Public assets (avatars, product images, logos). Public bucket URLs are permanent and bypass all access policies.
      
      ---
      
      ## Pattern 4: File Management
      
      ### Good Example — List, Move, Delete
      
      ```typescript
      const BUCKET_NAME = "documents";
      
      // List files in a folder
      async function listUserFiles(userId: string) {
        const { data, error } = await supabase.storage
          .from(BUCKET_NAME)
          .list(`${userId}/uploads`, {
            limit: 100,
            offset: 0,
            sortBy: { column: "created_at", order: "desc" },
          });
      
        if (error) {
          throw new Error(`Failed to list files: ${error.message}`);
        }
      
        return data;
      }
      
      // Delete files
      async function deleteFiles(filePaths: string[]) {
        const { error } = await supabase.storage.from(BUCKET_NAME).remove(filePaths);
      
        if (error) {
          throw new Error(`Failed to delete files: ${error.message}`);
        }
      }
      
      // Move/rename a file
      async function moveFile(fromPath: string, toPath: string) {
        const { error } = await supabase.storage
          .from(BUCKET_NAME)
          .move(fromPath, toPath);
      
        if (error) {
          throw new Error(`Failed to move file: ${error.message}`);
        }
      }
      ```
      
      **Why good:** Pagination with limit/offset, sorting by creation date, batch delete with array of paths, move for rename operations
      
      ---
      
      ## Pattern 5: Storage Bucket Policies (RLS)
      
      ### Good Example — User-Scoped Bucket Access
      
      ```sql
      -- Storage uses RLS on the storage.objects table
      -- Users can upload to their own folder
      create policy "Users can upload to own folder"
      on storage.objects for insert
      to authenticated
      with check (
        bucket_id = 'documents'
        and (select auth.uid())::text = (storage.foldername(name))[1]
      );
      
      -- Users can read their own files
      create policy "Users can read own files"
      on storage.objects for select
      to authenticated
      using (
        bucket_id = 'documents'
        and (select auth.uid())::text = (storage.foldername(name))[1]
      );
      
      -- Users can update (overwrite) their own files
      create policy "Users can update own files"
      on storage.objects for update
      to authenticated
      using (
        bucket_id = 'documents'
        and (select auth.uid())::text = (storage.foldername(name))[1]
      );
      
      -- Users can delete their own files
      create policy "Users can delete own files"
      on storage.objects for delete
      to authenticated
      using (
        bucket_id = 'documents'
        and (select auth.uid())::text = (storage.foldername(name))[1]
      );
      ```
      
      **Why good:** Policies on `storage.objects` table, `storage.foldername(name)` extracts folder from path, first folder is user ID, separate policies per operation, bucket_id check restricts to specific bucket
      
      ### Good Example — Public Avatars Bucket
      
      ```sql
      -- Anyone can view avatars
      create policy "Public avatar access"
      on storage.objects for select
      to anon, authenticated
      using ( bucket_id = 'avatars' );
      
      -- Only authenticated users can upload their own avatar
      create policy "Users upload own avatar"
      on storage.objects for insert
      to authenticated
      with check (
        bucket_id = 'avatars'
        and (select auth.uid())::text = (storage.foldername(name))[1]
      );
      ```
      
      **Why good:** Public read access for avatars, write restricted to own folder, combines public and authenticated roles appropriately
      
      ---
      
      ## Pattern 6: Signed Upload URLs (Client-Side Upload)
      
      ### Good Example — Server Creates URL, Client Uploads
      
      ```typescript
      // Server-side: Create a signed upload URL
      async function createUploadUrl(filePath: string) {
        const { data, error } = await supabaseAdmin.storage
          .from("documents")
          .createSignedUploadUrl(filePath);
      
        if (error) {
          throw new Error(`Failed to create upload URL: ${error.message}`);
        }
      
        return data;
      }
      
      // Client-side: Upload directly to the signed URL
      async function uploadToSignedUrl(signedUrl: string, token: string, file: File) {
        const { data, error } = await supabase.storage
          .from("documents")
          .uploadToSignedUrl(signedUrl, token, file);
      
        if (error) {
          throw new Error(`Upload failed: ${error.message}`);
        }
      
        return data;
      }
      ```
      
      **Why good:** Server controls where files can be uploaded (security), client uploads directly to storage (no server proxy needed), signed upload URLs expire after 2 hours
      
      **When to use:** Large file uploads where you want the client to upload directly to storage without proxying through your server. The server creates the signed URL (controlling path and permissions), and the client uploads directly.
      
      ---
      
      _For auth patterns, see [auth.md](auth.md). For database queries, see [database.md](database.md). For edge functions, see [edge-functions.md](edge-functions.md)._
      
  • reference.md 8.1 KB
    # Supabase Reference
    
    > Supabase CLI commands, environment setup, type generation, and quick lookup tables. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples.
    
    ---
    
    ## Supabase CLI Commands
    
    ### Project Setup
    
    ```bash
    # Install CLI
    npm i supabase --save-dev
    
    # Login and initialize
    npx supabase login
    npx supabase init
    
    # Start local development environment (Docker required)
    npx supabase start
    
    # Stop local services
    npx supabase stop
    ```
    
    ### Type Generation
    
    ```bash
    # Generate types from remote project
    npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > database.types.ts
    
    # Generate types from local dev database
    npx supabase gen types typescript --local > database.types.ts
    
    # Generate types from direct connection
    npx supabase gen types typescript --db-url "postgres://..." > database.types.ts
    ```
    
    ### Database Migrations
    
    ```bash
    # Create a new migration file
    npx supabase migration new <migration_name>
    # Creates: supabase/migrations/<timestamp>_<migration_name>.sql
    
    # Push local migrations to remote database
    npx supabase db push
    
    # Preview migration changes without applying
    npx supabase db push --dry-run
    
    # Reset local database (reapplies all migrations + seed.sql)
    npx supabase db reset
    
    # List migration status (local vs remote)
    npx supabase migration list
    ```
    
    ### Edge Functions
    
    ```bash
    # Create a new edge function
    npx supabase functions new <function_name>
    # Creates: supabase/functions/<function_name>/index.ts
    
    # Serve functions locally (with hot reload)
    npx supabase functions serve
    
    # Serve with debug inspector
    npx supabase functions serve --inspect-mode brk
    
    # Deploy a specific function
    npx supabase functions deploy <function_name>
    
    # Deploy all functions
    npx supabase functions deploy
    
    # Set a secret for edge functions
    npx supabase secrets set MY_SECRET=my_value
    
    # List secrets
    npx supabase secrets list
    ```
    
    ---
    
    ## Environment Variables
    
    ```bash
    # .env.local
    SUPABASE_URL=https://your-project-ref.supabase.co
    SUPABASE_PUBLISHABLE_KEY=sb_publishable_...  # Safe for client-side (formerly ANON_KEY)
    SUPABASE_SECRET_KEY=sb_secret_...            # SERVER ONLY — never expose to client (formerly SERVICE_ROLE_KEY)
    ```
    
    ---
    
    ## Type Helper Shortcuts
    
    ```typescript
    import type { Database, Tables, Enums } from "./database.types";
    
    // Full table row type
    type Post = Tables<"posts">;
    // Equivalent to: Database['public']['Tables']['posts']['Row']
    
    // Insert type (omits auto-generated columns)
    type PostInsert = Database["public"]["Tables"]["posts"]["Insert"];
    
    // Update type (all columns optional)
    type PostUpdate = Database["public"]["Tables"]["posts"]["Update"];
    
    // Enum type
    type PostStatus = Enums<"post_status">;
    ```
    
    ---
    
    ## Query Builder Quick Reference
    
    | Operation          | Code                                       |
    | ------------------ | ------------------------------------------ |
    | Select all columns | `.select("*")`                             |
    | Select specific    | `.select("id, title, created_at")`         |
    | Select with join   | `.select("id, author:profiles(username)")` |
    | Equality filter    | `.eq("column", value)`                     |
    | Not equal          | `.neq("column", value)`                    |
    | Greater than       | `.gt("column", value)`                     |
    | Less than          | `.lt("column", value)`                     |
    | In array           | `.in("column", [val1, val2])`              |
    | Pattern match      | `.like("column", "%pattern%")`             |
    | Case-insensitive   | `.ilike("column", "%pattern%")`            |
    | Is null            | `.is("column", null)`                      |
    | Contains (array)   | `.contains("tags", ["supabase"])`          |
    | Full-text search   | `.textSearch("column", "query")`           |
    | Order by           | `.order("column", { ascending: false })`   |
    | Limit rows         | `.limit(10)`                               |
    | Pagination         | `.range(0, 9)`                             |
    | Single row         | `.single()`                                |
    | Maybe single       | `.maybeSingle()`                           |
    | Return data        | `.select()` (after insert/update)          |
    
    ---
    
    ## Auth Events Reference
    
    | Event               | When Fired                                                    |
    | ------------------- | ------------------------------------------------------------- |
    | `INITIAL_SESSION`   | After client initializes and loads stored session             |
    | `SIGNED_IN`         | User session confirmed, re-established, or tab focus regained |
    | `SIGNED_OUT`        | User signs out or session expires                             |
    | `TOKEN_REFRESHED`   | New access and refresh tokens generated                       |
    | `USER_UPDATED`      | After `supabase.auth.updateUser()` completes                  |
    | `PASSWORD_RECOVERY` | User lands on a password reset page                           |
    
    ---
    
    ## RLS Policy Quick Reference
    
    | Clause                   | Used For                               | Operations                            |
    | ------------------------ | -------------------------------------- | ------------------------------------- |
    | `USING (condition)`      | Filter which rows are visible/affected | SELECT, UPDATE (existing row), DELETE |
    | `WITH CHECK (condition)` | Validate new/modified data             | INSERT, UPDATE (new row values)       |
    
    | Role            | Description                                         |
    | --------------- | --------------------------------------------------- |
    | `anon`          | Unauthenticated requests (public API)               |
    | `authenticated` | Logged-in users                                     |
    | `service_role`  | Server-side admin via secret key (bypasses all RLS) |
    
    ---
    
    ## Realtime Filter Operators
    
    | Operator | Example                      | Description              |
    | -------- | ---------------------------- | ------------------------ |
    | `eq`     | `id=eq.5`                    | Equals                   |
    | `neq`    | `status=neq.draft`           | Not equals               |
    | `gt`     | `age=gt.18`                  | Greater than             |
    | `gte`    | `age=gte.18`                 | Greater than or equal    |
    | `lt`     | `price=lt.100`               | Less than                |
    | `lte`    | `price=lte.100`              | Less than or equal       |
    | `in`     | `status=in.(active,pending)` | In list (max 100 values) |
    
    **Note:** DELETE events cannot be filtered.
    
    ---
    
    ## Storage Methods Quick Reference
    
    | Method                              | Description                                                |
    | ----------------------------------- | ---------------------------------------------------------- |
    | `.upload(path, file, options)`      | Upload a file (options: cacheControl, contentType, upsert) |
    | `.download(path)`                   | Download a file as Blob                                    |
    | `.getPublicUrl(path)`               | Get permanent public URL (public buckets only)             |
    | `.createSignedUrl(path, expiresIn)` | Get temporary signed URL                                   |
    | `.createSignedUploadUrl(path)`      | Get a URL for client-side upload (expires in 2h)           |
    | `.remove([path1, path2])`           | Delete files                                               |
    | `.list(folder, options)`            | List files in a folder                                     |
    | `.move(from, to)`                   | Move/rename a file                                         |
    | `.copy(from, to)`                   | Copy a file                                                |
    
    ---
    
    ## Edge Function Project Structure
    
    ```
    supabase/
    ├── functions/
    │   ├── _shared/           # Shared utilities (import via relative path)
    │   │   ├── cors.ts        # Reusable CORS headers
    │   │   └── supabase.ts    # Shared client setup
    │   ├── hello-world/
    │   │   └── index.ts       # Function entry point
    │   └── process-webhook/
    │       └── index.ts
    ├── migrations/
    │   ├── 20240101_create_posts.sql
    │   └── 20240102_add_rls.sql
    ├── seed.sql               # Test data for local dev
    └── config.toml            # Supabase project config
    ```
    
  • SKILL.md 15.9 KB
    ---
    name: api-baas-supabase
    description: Supabase backend-as-a-service — Auth, Database, Realtime, Storage, Edge Functions, RLS policies, typed client
    ---
    
    # Supabase Patterns
    
    > **Quick Guide:** Use Supabase as your backend-as-a-service for Postgres database, authentication, realtime subscriptions, file storage, and edge functions. Always use the typed client with `Database` generic, enable RLS on every table, and use the secret key only on the server.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST enable Row Level Security (RLS) on EVERY table in an exposed schema — no exceptions)**
    
    **(You MUST use the `Database` generic type with `createClient<Database>()` for type-safe queries)**
    
    **(You MUST NEVER expose the secret key in client-side code — use the publishable key in browsers, the secret key only on the server)**
    
    **(You MUST use `(select auth.uid())` wrapped in a subquery inside RLS policies for performance)**
    
    **(You MUST handle all Supabase responses with `{ data, error }` destructuring — never assume success)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** Supabase, createClient, @supabase/supabase-js, @supabase/ssr, supabase-js, auth.uid(), RLS, row level security, realtime, postgres_changes, supabase.auth, supabase.from, supabase.storage, supabase.functions, supabase.channel, edge function, Deno.serve
    
    **When to use:**
    
    - Setting up a Supabase client with TypeScript type safety
    - Implementing authentication (email/password, OAuth, magic links, session management)
    - Querying Postgres via the Supabase client (select, insert, update, delete, RPC)
    - Writing Row Level Security policies for data access control
    - Subscribing to database changes in real time
    - Uploading and serving files from Supabase Storage
    - Building serverless functions with Supabase Edge Functions (Deno)
    
    **Key patterns covered:**
    
    - Typed client setup with `Database` generic and environment variables
    - Auth flows: sign up, sign in, OAuth, magic link, session refresh, `onAuthStateChange`
    - Database queries with filters, joins, RPC calls, and error handling
    - RLS policies: `USING` vs `WITH CHECK`, `auth.uid()`, role-based access
    - Realtime subscriptions via `channel().on('postgres_changes')`
    - Storage: upload, signed URLs, public URLs, bucket policies
    - Edge Functions: `Deno.serve`, CORS headers, secrets, Supabase client in functions
    
    **When NOT to use:**
    
    - Direct Postgres connections (use a database driver skill instead)
    - Complex server-side ORM patterns (use a dedicated ORM skill)
    - Non-Supabase authentication providers (use dedicated auth skills)
    
    **Detailed Resources:**
    
    - For decision frameworks and anti-patterns, see [reference.md](reference.md)
    
    **Client & Queries:**
    
    - [examples/core.md](examples/core.md) — Client setup, typed queries, error handling patterns
    
    **Authentication:**
    
    - [examples/auth.md](examples/auth.md) — Full auth flows, OAuth, magic links, session refresh, middleware protection
    
    **Database:**
    
    - [examples/database.md](examples/database.md) — Complex queries, joins, RPC, migrations, type generation
    
    **Storage:**
    
    - [examples/storage.md](examples/storage.md) — File upload, signed URLs, bucket policies, image transforms
    
    **Edge Functions:**
    
    - [examples/edge-functions.md](examples/edge-functions.md) — Deno edge functions, `Deno.serve()`, CORS, secrets
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    Supabase is an open-source Firebase alternative built on Postgres. It provides a complete backend through a combination of Postgres extensions, auto-generated REST/GraphQL APIs, authentication, realtime subscriptions, file storage, and edge functions.
    
    **Core principles:**
    
    1. **Postgres at the core** — Every feature is built on Postgres. RLS policies, auth, and realtime all leverage Postgres primitives. Understanding Postgres is understanding Supabase.
    2. **Type safety end-to-end** — Generate TypeScript types from your database schema with `supabase gen types`. Pass the `Database` generic to `createClient` for fully typed queries.
    3. **Security by default** — RLS must be enabled on every table. The publishable key is safe for browsers (RLS enforces access). The secret key bypasses RLS and must never leave the server.
    4. **Error as values** — Every Supabase method returns `{ data, error }`. Never assume success. Always check `error` before using `data`.
    5. **Realtime built in** — Postgres changes stream over WebSockets via channels. No separate pub/sub infrastructure needed.
    6. **Edge-first functions** — Edge Functions run Deno at the edge, close to users. Design for short-lived, idempotent operations.
    
    **When to use Supabase:**
    
    - Rapid backend development with Postgres, auth, and storage out of the box
    - Projects needing realtime features (chat, notifications, live dashboards)
    - Teams wanting to avoid managing separate auth, database, and storage services
    - Applications that benefit from Row Level Security for multi-tenant data isolation
    
    **When NOT to use:**
    
    - Complex server-side business logic requiring a full application server (use Edge Functions for simple cases, a dedicated API for complex ones)
    - Applications needing an ORM with advanced query building (Supabase query builder is powerful but not a full ORM)
    - Offline-first applications requiring complex sync protocols
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: Typed Client Setup
    
    Always pass the `Database` generic to `createClient` for full autocomplete on table names, column names, and return types. Use environment variables for URL and keys.
    
    ```typescript
    export const supabase = createClient<Database>(
      SUPABASE_URL,
      SUPABASE_PUBLISHABLE_KEY,
    );
    ```
    
    Without the generic, typos in table/column names are not caught at compile time. See [examples/core.md](examples/core.md) for browser, server, and admin client setup patterns.
    
    ---
    
    ### Pattern 2: Error Handling with { data, error }
    
    Every Supabase method returns `{ data, error }`. Always destructure and check `error` before using `data`. Never use non-null assertions on `data`.
    
    ```typescript
    const { data, error } = await supabase
      .from("profiles")
      .select("id, username")
      .eq("id", userId)
      .single();
    if (error) throw new Error(`Failed to fetch profile: ${error.message}`);
    ```
    
    See [examples/core.md](examples/core.md) for the reusable error handler pattern and common mistakes.
    
    ---
    
    ### Pattern 3: Authentication Flows
    
    Supabase Auth supports email/password (`signInWithPassword`), OAuth (`signInWithOAuth`), magic links (`signInWithOtp`), and phone OTP. Register `onAuthStateChange` early in the app lifecycle and always clean up with `subscription.unsubscribe()`.
    
    Key gotcha: Do NOT call Supabase methods directly inside `onAuthStateChange` — use `setTimeout(..., 0)` to defer.
    
    See [examples/auth.md](examples/auth.md) for sign up, sign in, OAuth, magic link, session management, middleware protection, and password reset patterns.
    
    ---
    
    ### Pattern 4: Database Queries
    
    Use the query builder for type-safe CRUD with filters, joins, ordering, and pagination. Always add `.select()` after `.insert()` or `.update()` to return the affected row.
    
    ```typescript
    const { data, error } = await supabase
      .from("posts")
      .select("id, title, author:profiles(username)")
      .eq("published", true)
      .order("created_at", { ascending: false })
      .range(0, PAGE_SIZE - 1);
    ```
    
    See [examples/database.md](examples/database.md) for complex queries, upserts, RPC calls, conditional filters, counting, and migrations.
    
    ---
    
    ### Pattern 5: Row Level Security (RLS) Policies
    
    RLS is the primary security mechanism. Enable it on every table, write separate policies per operation (not `FOR ALL`), and wrap `auth.uid()` in a subquery for performance.
    
    ```sql
    alter table public.posts enable row level security;
    
    create policy "posts_select" on public.posts for select to authenticated
    using ( published = true or (select auth.uid()) = author_id );
    ```
    
    Never trust `user_metadata` from JWT for access control — it is user-modifiable. See [examples/database.md](examples/database.md) for full CRUD policies, team-based access, and anti-patterns.
    
    ---
    
    ### Pattern 6: Realtime Subscriptions
    
    Subscribe to database changes via `channel().on('postgres_changes', ...)`. Always unsubscribe on cleanup. DELETE events cannot be filtered — all deletes are received. UPDATE/DELETE payloads need `replica identity full` for old record data.
    
    ```typescript
    const channel = supabase
      .channel("room-messages")
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "messages",
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          /* handle */
        },
      )
      .subscribe();
    ```
    
    Use for chat, live dashboards, notifications. Avoid for high-frequency data (> 100 updates/sec).
    
    ---
    
    ### Pattern 7: Storage Operations
    
    Upload files with `supabase.storage.from(bucket).upload()`. Use `getPublicUrl()` for public buckets, `createSignedUrl()` for private buckets with time-limited access. Storage access control uses RLS on `storage.objects`.
    
    See [examples/storage.md](examples/storage.md) for upload, signed URLs, public URLs, image transforms, bucket policies, and signed upload URLs.
    
    ---
    
    ### Pattern 8: Edge Functions
    
    Use `Deno.serve()` (not the deprecated `serve` import). Import supabase-js with `npm:` prefix: `import { createClient } from "npm:@supabase/supabase-js@2"`. Handle CORS on every response. Use `Deno.env.get()` for secrets. Forward user JWT for RLS enforcement.
    
    See [examples/edge-functions.md](examples/edge-functions.md) for basic functions, authenticated access, shared utilities, webhooks, multi-route "fat functions", and background processing with `EdgeRuntime.waitUntil()`.
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Which Supabase Key to Use
    
    ```
    Where is the code running?
    ├─ Browser / Client-side → publishable key (RLS enforced)
    ├─ Server / API route → publishable key + user JWT (RLS enforced per user)
    └─ Admin / Migration script → secret key (bypasses RLS)
        └─ NEVER expose the secret key in client bundles
    ```
    
    ### Auth Method Selection
    
    ```
    What auth flow does the user need?
    ├─ Email + Password → signInWithPassword
    ├─ Social login (GitHub, Google, etc.) → signInWithOAuth
    ├─ Passwordless email → signInWithOtp (magic link)
    ├─ Phone + SMS → signInWithOtp (phone)
    └─ SSO / SAML → signInWithSSO (enterprise)
    ```
    
    ### Realtime vs Polling
    
    ```
    How fresh must the data be?
    ├─ Instant (< 1 second) → Realtime subscription (postgres_changes)
    ├─ Near-instant (1-5 seconds) → Realtime subscription
    ├─ Periodic (> 5 seconds ok) → Polling with setInterval
    └─ On-demand (user refresh) → Re-fetch on action
        └─ High-frequency updates (> 100/sec)?
            ├─ YES → Polling or batch (Realtime has per-subscriber checks)
            └─ NO → Realtime is fine
    ```
    
    ### Storage: Public vs Private Buckets
    
    ```
    Who should access the files?
    ├─ Anyone (public assets, avatars) → Public bucket + getPublicUrl()
    ├─ Authenticated users only → Private bucket + createSignedUrl()
    ├─ Specific users (own files) → Private bucket + RLS on storage.objects
    └─ Server-only processing → secret key for upload/download
    ```
    
    ### Edge Functions vs Client Queries
    
    ```
    Does the operation need server-side logic?
    ├─ Simple CRUD → Client query with RLS (no edge function needed)
    ├─ Multi-step / transactional → Edge function or Postgres function (RPC)
    ├─ Third-party API call → Edge function
    ├─ Webhook receiver → Edge function
    └─ Heavy computation → Edge function with EdgeRuntime.waitUntil() for background work
    ```
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - **Missing RLS on tables** — Any table without RLS in an exposed schema is completely open to the public. In January 2025, 170+ apps were found with exposed databases due to missing RLS (CVE-2025-48757).
    - **Secret key in client code** — The secret key (formerly `service_role` key) bypasses all RLS. Exposing it in browser bundles gives every user full admin database access.
    - **Ignoring `{ data, error }` returns** — Accessing `data` without checking `error` leads to runtime crashes when operations fail.
    - **Using `auth.jwt() ->> 'user_metadata'` in RLS policies** — `user_metadata` is modifiable by authenticated users via `updateUser()`. Never use it for access control decisions.
    
    **Medium Priority Issues:**
    
    - **Using `FOR ALL` in RLS policies** — Separate into `SELECT`, `INSERT`, `UPDATE`, `DELETE` policies for clarity and auditability.
    - **Bare `auth.uid()` in policies without subquery** — Wrap in `(select auth.uid())` for up to 94-99% performance improvement per Supabase benchmarks.
    - **Not specifying `to authenticated` or `to anon` in policies** — Without a role, policies apply to all roles, which may expose data unintentionally.
    - **Using `select("*")` everywhere** — Fetches all columns including sensitive data. Select only the columns you need.
    - **Deprecated `serve` import in Edge Functions** — `import { serve } from "https://deno.land/std/http/server.ts"` is deprecated. Use `Deno.serve()`.
    
    **Common Mistakes:**
    
    - **Not adding `.select()` after `.insert()` or `.update()`** — Without `.select()`, these methods return no data (only `null`).
    - **Missing CORS headers in Edge Functions** — Browser requests fail without proper CORS headers and OPTIONS handling.
    - **Not unsubscribing from Realtime channels** — Leaks WebSocket connections and can cause memory issues.
    - **Using bare specifiers in Edge Functions** — `import { createClient } from "@supabase/supabase-js"` fails in Deno. Use `npm:@supabase/supabase-js@2`.
    - **Using `getSession()` to verify auth** — `getSession()` reads from local storage and can be tampered with. Use `getUser()` for secure server-side verification.
    
    **Gotchas & Edge Cases:**
    
    - **Realtime DELETE events cannot be filtered** — All deletes for a subscribed table are received regardless of filter.
    - **Realtime requires `replica identity full` for old record data** — By default, UPDATE and DELETE payloads only include the new record. Set `alter table X replica identity full` to access `payload.old`.
    - **RLS policies are not applied to Realtime DELETE events** — Be cautious about what information DELETE events expose.
    - **`onAuthStateChange` fires on tab focus** — `SIGNED_IN` events fire when a browser tab regains focus, not just on actual sign-in.
    - **Do NOT call Supabase methods inside `onAuthStateChange` callback** — This can cause deadlocks. Use `setTimeout(..., 0)` to defer.
    - **Signed URLs expire** — `createSignedUrl()` URLs expire after the specified duration. Signed upload URLs expire after 2 hours.
    - **Public bucket URLs bypass RLS** — Files in public buckets are accessible to anyone with the URL, regardless of policies.
    - **Edge Function cold starts** — First invocation after idle period has additional latency. Design "fat functions" (fewer, larger functions) to minimize cold starts.
    - **Edge Functions: file writes only on `/tmp`** — The `/tmp` directory is the only writable path in edge functions.
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST enable Row Level Security (RLS) on EVERY table in an exposed schema — no exceptions)**
    
    **(You MUST use the `Database` generic type with `createClient<Database>()` for type-safe queries)**
    
    **(You MUST NEVER expose the secret key in client-side code — use the publishable key in browsers, the secret key only on the server)**
    
    **(You MUST use `(select auth.uid())` wrapped in a subquery inside RLS policies for performance)**
    
    **(You MUST handle all Supabase responses with `{ data, error }` destructuring — never assume success)**
    
    **Failure to follow these rules will create security vulnerabilities, type-unsafe queries, and silent runtime failures.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related