api-baas-supabase
Supabase backend-as-a-service — Auth, Database, Realtime, Storage, Edge Functions, RLS policies, typed client
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-baas-supabase/skills/api-baas-supabase
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
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
Databasegeneric, 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
Databasegeneric 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:
USINGvsWITH 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:
- examples/core.md — Client setup, typed queries, error handling patterns
Authentication:
- examples/auth.md — Full auth flows, OAuth, magic links, session refresh, middleware protection
Database:
- examples/database.md — Complex queries, joins, RPC, migrations, type generation
Storage:
- examples/storage.md — File upload, signed URLs, bucket policies, image transforms
Edge Functions:
- examples/edge-functions.md — Deno edge functions,
Deno.serve(), CORS, secrets
<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_rolekey) bypasses all RLS. Exposing it in browser bundles gives every user full admin database access. - Ignoring
{ data, error }returns — Accessingdatawithout checkingerrorleads to runtime crashes when operations fail. - Using
auth.jwt() ->> 'user_metadata'in RLS policies —user_metadatais modifiable by authenticated users viaupdateUser(). Never use it for access control decisions.
Medium Priority Issues:
- Using
FOR ALLin RLS policies — Separate intoSELECT,INSERT,UPDATE,DELETEpolicies 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 authenticatedorto anonin 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
serveimport in Edge Functions —import { serve } from "https://deno.land/std/http/server.ts"is deprecated. UseDeno.serve().
Common Mistakes:
- Not adding
.select()after.insert()or.update()— Without.select(), these methods return no data (onlynull). - 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. Usenpm:@supabase/supabase-js@2. - Using
getSession()to verify auth —getSession()reads from local storage and can be tampered with. UsegetUser()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 fullfor old record data — By default, UPDATE and DELETE payloads only include the new record. Setalter table X replica identity fullto accesspayload.old. - RLS policies are not applied to Realtime DELETE events — Be cautious about what information DELETE events expose.
onAuthStateChangefires on tab focus —SIGNED_INevents fire when a browser tab regains focus, not just on actual sign-in.- Do NOT call Supabase methods inside
onAuthStateChangecallback — This can cause deadlocks. UsesetTimeout(..., 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/tmpdirectory 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.
Reviews (0)
No reviews yet.
No comments yet.