
Supabase Ts
- 8 installs
- 5 repo stars
- Updated August 5, 2026
- bjornmelin/dev-skills
Supabase TS is a Claude Code skill providing production Supabase integration patterns for Next.js, React, and TypeScript apps.
About
Supabase TS gives production integration patterns for using Supabase in Next.js, React, and TypeScript apps. It shows SSR-correct server, browser, and middleware clients, plus auth, Row Level Security, storage, realtime, and Edge Functions patterns. A developer uses it when wiring Supabase into a Next.js App Router project and wants to avoid common auth and RLS mistakes. It also covers pgvector semantic search, Vercel connection pooling, and Zod v4 response validation.
- SSR-correct server, browser, and middleware Supabase clients for Next.js App Router with @supabase/ssr
- Auth uses getUser() to validate the JWT and RLS wraps auth.uid() in a subquery
- Covers storage signed URLs, realtime broadcast channels, pgvector search, and Zod v4 validation
Supabase Ts by the numbers
- 8 all-time installs (skills.sh)
- Ranked #3,619 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
supabase-ts capabilities & compatibility
- Capabilities
- supabase auth · row level security · storage uploads · realtime channels · vector search · edge functions
- Works with
- supabase · postgres · vercel
- Use cases
- api development · database · security audit
- IDEs
- vscode · cursor ide
What supabase-ts says it does
// CORRECT: Validates JWT with auth server
Production patterns for Supabase in Next.js/React/Vercel applications with TypeScript and Zod v4.
npx skills add https://github.com/bjornmelin/dev-skills --skill supabase-tsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | bjornmelin/dev-skills ↗ |
What it does
Wire Supabase auth, RLS, storage, realtime, and Edge Functions into Next.js/TypeScript apps with SSR-correct clients.
Who is it for?
Integrating Supabase auth, RLS, storage, realtime, and Edge Functions correctly into a Next.js App Router app.
Skip if: Non-Supabase backends or non-TypeScript stacks.
When should I use this skill?
Setting up Supabase clients, auth, RLS policies, storage uploads, realtime, Edge Functions, or vector search in Next.js.
What you get
A Next.js app with SSR-safe Supabase clients, validated auth, efficient RLS, and secure storage.
- Supabase client setup
- RLS policies
- auth/middleware wiring
By the numbers
- 9 reference guides bundled
- 5-context client decision table
Files
Supabase TypeScript
Production patterns for Supabase in Next.js/React/Vercel applications with TypeScript and Zod v4.
Quick Reference
Server Client (Next.js App Router)
// src/lib/supabase/server.ts
import "server-only";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import type { Database } from "./database.types";
export async function createServerSupabase() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
}
);
}Browser Client (React)
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "./database.types";
let client: ReturnType<typeof createBrowserClient<Database>> | null = null;
export function getBrowserClient() {
if (client) return client;
if (typeof window === "undefined") return null;
client = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
return client;
}Middleware Client
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return response;
}Decision Framework
| Context | Client | Why |
|---|---|---|
| Server Component | createServerSupabase() | Async cookies, server-only |
| Route Handler | createServerSupabase() | SSR context with cookies |
| Middleware | Inline createServerClient | Edge runtime, request/response cookies |
| Client Component | getBrowserClient() | Singleton, SSR-safe (null check) |
| Server Action | createServerSupabase() | Server context |
Core Patterns
Auth: Always Use getUser()
// CORRECT: Validates JWT with auth server
const { data: { user } } = await supabase.auth.getUser();
// WRONG: Only reads from cookie, can be spoofed
const { data: { session } } = await supabase.auth.getSession();RLS: Use Subquery Wrapper
-- CORRECT: Subquery prevents multiple auth.uid() calls
create policy "Users view own data"
on public.items for select
to authenticated
using ((select auth.uid()) = user_id);
-- WRONG: Direct call, inefficient
using (auth.uid() = user_id);Realtime: Prefer Broadcast
// Broadcast: Low latency, no DB polling
const channel = supabase.channel("room:123", { config: { private: true } });
channel.send({ type: "broadcast", event: "cursor", payload: { x, y } });
// postgres_changes: Higher latency, DB trigger required
channel.on("postgres_changes", { event: "*", schema: "public", table: "messages" }, handler);Storage: Signed URLs for Private Files
// Public bucket: Direct URL
const { data } = supabase.storage.from("public-bucket").getPublicUrl("file.jpg");
// Private bucket: Time-limited signed URL
const { data } = await supabase.storage
.from("private-bucket")
.createSignedUrl("file.jpg", 3600); // 1 hourZod v4 Integration
import { z } from "zod";
// Use top-level string helpers (Zod v4)
const UserSchema = z.strictObject({
id: z.uuid(),
email: z.email(),
created_at: z.iso.datetime(),
metadata: z.looseObject({
avatar_url: z.url().optional(),
}),
});
// Unified error option (Zod v4)
const InsertSchema = z.strictObject({
title: z.string().min(1, { error: "Title required" }),
user_id: z.uuid({ error: "Invalid user ID" }),
});
// Parse Supabase response
const { data, error } = await supabase.from("items").select("*");
if (error) throw error;
const parsed = z.array(ItemSchema).parse(data);Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
getSession() for auth validation | Use getUser() - validates JWT |
auth.uid() directly in RLS | Wrap in (select auth.uid()) |
| Module-scope Supabase client | Create inside request handler |
| Service role key on client | Server-only, never expose |
postgres_changes for chat | Use broadcast channels |
Caching auth responses ('use cache') | Keep auth routes dynamic |
CLI Quick Reference
# Setup
supabase login
supabase link --project-ref <ref>
# Type generation
supabase gen types typescript --project-id <ref> --schema public > database.types.ts
# Migrations
supabase migration new <name>
supabase db push # Push local migrations to remote
supabase db pull # Pull remote schema to local
supabase db diff # Show schema differences
supabase db reset # Reset local database
# Edge Functions
supabase functions serve # Local development
supabase functions deploy <name> # Deploy to productionReference Documentation
Navigate to detailed guides based on task:
Core Setup & Operations
- [CLI Mastery](references/cli-mastery.md): Complete CLI workflow, type generation, migrations
- [Database](references/database.md): Migrations, pgvector, functions, extensions
- [Vercel Deployment](references/vercel-deployment.md): Integration, pooling, env vars
Authentication & Security
- [Auth SSR](references/auth-ssr.md): @supabase/ssr setup, PKCE, OAuth, middleware
- [RLS Cookbook](references/rls-cookbook.md): Policy patterns, team access, storage RLS
Data & Features
- [Storage](references/storage.md): Buckets, uploads, transformations, signed URLs
- [Realtime](references/realtime.md): Broadcast, presence, authorization
- [AI Vectors](references/ai-vectors.md): pgvector, embeddings, semantic search
- [Edge Functions](references/edge-functions.md): Deno runtime, deployment, CORS
Templates
Migration Template
-- supabase/migrations/YYYYMMDDHHmmss_description.sql
-- Enable required extensions
create extension if not exists "uuid-ossp";
-- Create table with RLS
create table public.items (
id uuid primary key default uuid_generate_v4(),
user_id uuid not null references auth.users(id) on delete cascade,
title text not null,
created_at timestamptz not null default now()
);
-- Enable RLS (mandatory)
alter table public.items enable row level security;
-- Policies
create policy "Users view own items"
on public.items for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users insert own items"
on public.items for insert
to authenticated
with check ((select auth.uid()) = user_id);
-- Indexes
create index items_user_id_idx on public.items(user_id);
comment on table public.items is 'User items with RLS';Edge Function Template
// supabase/functions/my-function/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization");
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader! } } }
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const body = await req.json();
// Process request...
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});GitHub Actions Type Generation
# .github/workflows/supabase-types.yml
name: Generate Supabase Types
on:
push:
paths: ["supabase/migrations/**"]
branches: [main]
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
- run: |
supabase gen types typescript \
--project-id ${{ secrets.SUPABASE_PROJECT_REF }} \
--schema public \
> src/lib/supabase/database.types.ts
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- uses: peter-evans/create-pull-request@v5
with:
commit-message: "chore: update database types"
title: "Update Supabase Database Types"
branch: update-db-typesAI Vectors Reference
pgvector setup, embeddings, and semantic search patterns.
Table of Contents
pgvector Setup
Enable Extension
-- In migration
create extension if not exists vector;Create Vector Table
create table public.documents (
id uuid primary key default gen_random_uuid(),
content text not null,
embedding vector(1536), -- Dimension matches your model
metadata jsonb default '{}',
created_at timestamptz not null default now()
);
alter table public.documents enable row level security;
-- RLS policy
create policy "Users view documents"
on public.documents for select
to authenticated
using (true);Embedding Models
Common Dimensions
| Model | Dimensions | Provider |
|---|---|---|
| text-embedding-3-small | 1536 | OpenAI |
| text-embedding-3-large | 3072 | OpenAI |
| text-embedding-ada-002 | 1536 | OpenAI |
| gte-small | 384 | Hugging Face |
| gte-base | 768 | Hugging Face |
| all-MiniLM-L6-v2 | 384 | Hugging Face |
Choose Dimensions Based On
| Factor | Small (384-512) | Medium (768) | Large (1536+) |
|---|---|---|---|
| Storage | Low | Medium | High |
| Speed | Fast | Medium | Slower |
| Accuracy | Good | Better | Best |
| Use case | Simple search | General use | High precision |
Vector Indexes
HNSW Index (Recommended)
Best for most production use cases.
create index documents_embedding_idx
on public.documents
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);HNSW Parameters
| Parameter | Default | Description |
|---|---|---|
m | 16 | Max connections per node (higher = better recall, more memory) |
ef_construction | 64 | Size of candidate list during build (higher = better quality, slower build) |
Query-time Parameter
-- Set ef_search for query (higher = better recall, slower)
set hnsw.ef_search = 100;IVFFlat Index
Better for frequently updated data.
create index documents_embedding_ivf_idx
on public.documents
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);IVFFlat Parameters
| Parameter | Guideline |
|---|---|
lists | sqrt(rows) for < 1M rows, rows/1000 for > 1M |
Query-time Parameter
-- Set probes for query (higher = better recall, slower)
set ivfflat.probes = 10;Distance Operators
| Operator | Name | Use |
|---|---|---|
<=> | Cosine distance | Most common for text |
<-> | L2/Euclidean distance | Image, audio |
<#> | Inner product | When vectors are normalized |
Semantic Search
Basic Match Function
create or replace function public.match_documents(
query_embedding vector(1536),
match_threshold float default 0.7,
match_count int default 10
)
returns table (
id uuid,
content text,
metadata jsonb,
similarity float
)
language sql
security invoker
set search_path = ''
as $$
select
d.id,
d.content,
d.metadata,
1 - (d.embedding <=> query_embedding) as similarity
from public.documents d
where 1 - (d.embedding <=> query_embedding) > match_threshold
order by d.embedding <=> query_embedding
limit least(match_count, 200);
$$;Call from TypeScript
// Generate embedding first
const embedding = await generateEmbedding(query);
// Search
const { data, error } = await supabase.rpc("match_documents", {
query_embedding: embedding,
match_threshold: 0.7,
match_count: 10,
});With Metadata Filter
create or replace function public.match_documents_filtered(
query_embedding vector(1536),
filter_metadata jsonb default '{}',
match_threshold float default 0.7,
match_count int default 10
)
returns table (
id uuid,
content text,
similarity float
)
language sql
security invoker
set search_path = ''
as $$
select
d.id,
d.content,
1 - (d.embedding <=> query_embedding) as similarity
from public.documents d
where
d.metadata @> filter_metadata and
1 - (d.embedding <=> query_embedding) > match_threshold
order by d.embedding <=> query_embedding
limit least(match_count, 200);
$$;const { data } = await supabase.rpc("match_documents_filtered", {
query_embedding: embedding,
filter_metadata: { category: "tech", language: "en" },
match_threshold: 0.7,
match_count: 10,
});Hybrid Search
Combine keyword (full-text) and semantic search for better results.
Setup Full-Text Search
-- Add tsvector column
alter table public.documents
add column fts tsvector
generated always as (to_tsvector('english', content)) stored;
-- Create GIN index
create index documents_fts_idx on public.documents using gin(fts);Hybrid Search Function
create or replace function public.hybrid_search(
query_text text,
query_embedding vector(1536),
match_count int default 10,
keyword_weight float default 0.3,
semantic_weight float default 0.7
)
returns table (
id uuid,
content text,
combined_score float
)
language plpgsql
security invoker
set search_path = ''
as $$
begin
return query
with keyword_results as (
select
d.id,
d.content,
ts_rank(d.fts, websearch_to_tsquery('english', query_text)) as rank
from public.documents d
where d.fts @@ websearch_to_tsquery('english', query_text)
limit match_count * 2
),
semantic_results as (
select
d.id,
d.content,
1 - (d.embedding <=> query_embedding) as rank
from public.documents d
order by d.embedding <=> query_embedding
limit match_count * 2
)
select
coalesce(k.id, s.id) as id,
coalesce(k.content, s.content) as content,
(
coalesce(k.rank, 0) * keyword_weight +
coalesce(s.rank, 0) * semantic_weight
) as combined_score
from keyword_results k
full outer join semantic_results s on k.id = s.id
order by combined_score desc
limit match_count;
end;
$$;Reciprocal Rank Fusion (RRF)
create or replace function public.hybrid_search_rrf(
query_text text,
query_embedding vector(1536),
match_count int default 10,
rrf_k int default 60
)
returns table (
id uuid,
content text,
rrf_score float
)
language plpgsql
security invoker
set search_path = ''
as $$
begin
return query
with keyword_results as (
select
d.id,
d.content,
row_number() over (
order by ts_rank(d.fts, websearch_to_tsquery('english', query_text)) desc
) as rank
from public.documents d
where d.fts @@ websearch_to_tsquery('english', query_text)
limit match_count * 2
),
semantic_results as (
select
d.id,
d.content,
row_number() over (order by d.embedding <=> query_embedding) as rank
from public.documents d
limit match_count * 2
)
select
coalesce(k.id, s.id) as id,
coalesce(k.content, s.content) as content,
(
coalesce(1.0 / (rrf_k + k.rank), 0) +
coalesce(1.0 / (rrf_k + s.rank), 0)
) as rrf_score
from keyword_results k
full outer join semantic_results s on k.id = s.id
order by rrf_score desc
limit match_count;
end;
$$;Embedding Generation
OpenAI Embeddings
import OpenAI from "openai";
const openai = new OpenAI();
async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return response.data[0].embedding;
}Batch Embedding
async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: texts,
});
return response.data.map((d) => d.embedding);
}Insert with Embedding
async function insertDocument(content: string, metadata: object = {}) {
const embedding = await generateEmbedding(content);
const { data, error } = await supabase.from("documents").insert({
content,
embedding,
metadata,
});
return { data, error };
}Edge Function for Embeddings
// supabase/functions/embed/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import OpenAI from "npm:openai@4";
import { createClient } from "npm:@supabase/supabase-js@2";
const openai = new OpenAI({ apiKey: Deno.env.get("OPENAI_API_KEY") });
Deno.serve(async (req) => {
const { content, metadata } = await req.json();
// Generate embedding
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: content,
});
const embedding = response.data[0].embedding;
// Store in Supabase
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { data, error } = await supabase.from("documents").insert({
content,
embedding,
metadata,
});
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
return Response.json({ id: data[0].id });
});Chunking Strategies
Fixed-Size Chunks
function chunkText(text: string, chunkSize: number = 1000, overlap: number = 200): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start += chunkSize - overlap;
}
return chunks;
}Sentence-Based Chunks
function chunkBySentences(text: string, maxChunkSize: number = 1000): string[] {
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
const chunks: string[] = [];
let currentChunk = "";
for (const sentence of sentences) {
if (currentChunk.length + sentence.length > maxChunkSize && currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = "";
}
currentChunk += sentence;
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
return chunks;
}Store Chunks with Source
create table public.document_chunks (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents(id) on delete cascade,
chunk_index int not null,
content text not null,
embedding vector(1536),
created_at timestamptz not null default now()
);
create index document_chunks_embedding_idx
on public.document_chunks
using hnsw (embedding vector_cosine_ops);Auth SSR Reference
Server-side authentication patterns for Next.js App Router with @supabase/ssr.
Table of Contents
- Package Setup
- Client Creation Patterns
- Auth Validation
- OAuth Providers
- Session Management
- Protected Routes
- Auth Callbacks
Package Setup
Install Dependencies
npm install @supabase/ssr @supabase/supabase-jsEnvironment Variables
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...Client Creation Patterns
Server Client (Route Handlers, Server Components)
// src/lib/supabase/server.ts
import "server-only";
import { cookies } from "next/headers";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import type { Database } from "./database.types";
export async function createServerSupabase() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Ignore errors when called from Server Component
}
},
},
}
);
}Browser Client
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "./database.types";
let client: ReturnType<typeof createBrowserClient<Database>> | null = null;
export function getBrowserClient() {
if (client) return client;
if (typeof window === "undefined") return null;
client = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
return client;
}
// React hook
export function useSupabase() {
return useMemo(getBrowserClient, []);
}Middleware Client
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Refresh session if needed
const { data: { user } } = await supabase.auth.getUser();
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};Auth Validation
getUser() vs getSession()
// CORRECT: Validates JWT with Supabase auth server
const { data: { user }, error } = await supabase.auth.getUser();
// WRONG: Only reads from cookie, can be spoofed
const { data: { session } } = await supabase.auth.getSession();Always use getUser() when:
- Checking authentication status
- Making authorization decisions
- Rendering protected content
Server Component Auth Check
// app/dashboard/page.tsx
import { createServerSupabase } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = await createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
return <Dashboard userId={user.id} />;
}Route Handler Auth Check
// app/api/profile/route.ts
import { createServerSupabase } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET() {
const supabase = await createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
return NextResponse.json(profile);
}OAuth Providers
Google OAuth
// Sign in with Google
async function signInWithGoogle() {
const supabase = getBrowserClient();
if (!supabase) return;
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: "offline",
prompt: "consent",
},
},
});
}GitHub OAuth
async function signInWithGitHub() {
const supabase = getBrowserClient();
if (!supabase) return;
const { error } = await supabase.auth.signInWithOAuth({
provider: "github",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
scopes: "read:user user:email",
},
});
}OAuth Configuration
In Supabase Dashboard → Authentication → Providers:
| Provider | Redirect URL |
|---|---|
https://<project-ref>.supabase.co/auth/v1/callback | |
| GitHub | https://<project-ref>.supabase.co/auth/v1/callback |
Session Management
Email/Password Sign Up
async function signUp(email: string, password: string) {
const supabase = getBrowserClient();
if (!supabase) return { error: new Error("Client unavailable") };
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
return { data, error };
}Email/Password Sign In
async function signIn(email: string, password: string) {
const supabase = getBrowserClient();
if (!supabase) return { error: new Error("Client unavailable") };
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { data, error };
}Sign Out
async function signOut() {
const supabase = getBrowserClient();
if (!supabase) return;
await supabase.auth.signOut();
window.location.href = "/";
}Password Reset
// Request reset email
async function resetPassword(email: string) {
const supabase = getBrowserClient();
if (!supabase) return;
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
return { error };
}
// Update password (after clicking email link)
async function updatePassword(newPassword: string) {
const supabase = getBrowserClient();
if (!supabase) return;
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
return { error };
}Protected Routes
Middleware Protection
// middleware.ts
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
const supabase = createServerClient(/* ... */);
const { data: { user } } = await supabase.auth.getUser();
// Protect dashboard routes
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("redirect", request.nextUrl.pathname);
return NextResponse.redirect(url);
}
// Redirect authenticated users from auth pages
if (user && (request.nextUrl.pathname === "/login" ||
request.nextUrl.pathname === "/signup")) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return response;
}Layout-Level Protection
// app/(protected)/layout.tsx
import { createServerSupabase } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
return <>{children}</>;
}Auth Callbacks
OAuth Callback Route
// app/auth/callback/route.ts
import { createServerSupabase } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next") ?? "/dashboard";
if (code) {
const supabase = await createServerSupabase();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(new URL(next, requestUrl.origin));
}
}
return NextResponse.redirect(new URL("/auth/error", requestUrl.origin));
}Email Confirmation Callback
// app/auth/confirm/route.ts
import { createServerSupabase } from "@/lib/supabase/server";
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const token_hash = requestUrl.searchParams.get("token_hash");
const type = requestUrl.searchParams.get("type") as EmailOtpType | null;
const next = requestUrl.searchParams.get("next") ?? "/";
if (token_hash && type) {
const supabase = await createServerSupabase();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
return NextResponse.redirect(new URL(next, requestUrl.origin));
}
}
return NextResponse.redirect(new URL("/auth/error", requestUrl.origin));
}Auth State Listener (Client)
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { getBrowserClient } from "@/lib/supabase/client";
export function AuthListener({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === "SIGNED_OUT") {
router.push("/login");
}
if (event === "SIGNED_IN") {
router.refresh();
}
});
return () => subscription.unsubscribe();
}, [router]);
return <>{children}</>;
}Supabase CLI Mastery
Complete reference for Supabase CLI workflows in development and CI/CD.
Table of Contents
- Installation & Setup
- Local Development
- Type Generation
- Migrations
- Edge Functions
- Database Operations
- CI/CD Integration
Installation & Setup
Install CLI
# macOS
brew install supabase/tap/supabase
# npm (cross-platform)
npm install -g supabase
# Windows (scoop)
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabaseAuthentication
# Login (opens browser)
supabase login
# Link to existing project
supabase link --project-ref <project-ref>
# Get project ref from dashboard URL: app.supabase.com/project/<project-ref>Local Development
Initialize Project
# Create supabase/ directory structure
supabase init
# Structure created:
# supabase/
# ├── config.toml # Local config
# ├── migrations/ # SQL migrations
# ├── functions/ # Edge Functions
# └── seed.sql # Seed dataStart Local Stack
# Start local Postgres, Auth, Storage, Realtime
supabase start
# Output includes:
# - API URL: http://localhost:54321
# - DB URL: postgresql://postgres:postgres@localhost:54322/postgres
# - Studio URL: http://localhost:54323
# - Anon key and service_role keyStop Local Stack
supabase stop # Stop containers (preserves data)
supabase stop --no-backup # Stop and remove volumesStatus Check
supabase status # Show running services and URLsType Generation
Generate from Remote
# Generate types from linked project
supabase gen types typescript --project-id <project-ref> --schema public > database.types.ts
# Multiple schemas
supabase gen types typescript --project-id <ref> --schema public,auth > database.types.tsGenerate from Local
# Generate from local database (requires supabase start)
supabase gen types typescript --local --schema public > database.types.tsType Generation Best Practices
// database.types.ts location
// Recommended: src/lib/supabase/database.types.ts
// Usage with client
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "./database.types";
const supabase = createBrowserClient<Database>(url, key);
// Now fully typed:
const { data } = await supabase.from("users").select("*");
// data is Database["public"]["Tables"]["users"]["Row"][]Migrations
Create Migration
# Create empty migration file
supabase migration new create_users_table
# Creates: supabase/migrations/YYYYMMDDHHmmss_create_users_table.sqlMigration File Format
-- supabase/migrations/20241210120000_create_users_table.sql
-- Up migration (create)
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text unique not null,
avatar_url text,
created_at timestamptz not null default now()
);
-- Enable RLS
alter table public.profiles enable row level security;
-- Create policies
create policy "Public profiles viewable"
on public.profiles for select
to authenticated
using (true);
create policy "Users update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id);
-- Add index
create index profiles_username_idx on public.profiles(username);Apply Migrations
# Apply to local database
supabase db reset # Reset and run all migrations
# Apply to remote (production)
supabase db push # Push local migrations to remote
# Pull remote schema changes
supabase db pull # Creates migration from remote changesSchema Diff
# Compare local schema with migrations
supabase db diff
# Generate migration from diff
supabase db diff --use-migra -f new_changesMigration Troubleshooting
# List applied migrations
supabase migration list
# Repair migration history
supabase migration repair --status applied <version>
supabase migration repair --status reverted <version>Edge Functions
Create Function
# Create new function
supabase functions new my-function
# Creates: supabase/functions/my-function/index.tsLocal Development
# Serve all functions locally
supabase functions serve
# Serve specific function
supabase functions serve my-function
# With environment variables
supabase functions serve --env-file ./supabase/.env.localDeploy Functions
# Deploy single function
supabase functions deploy my-function
# Deploy all functions
supabase functions deploy
# Deploy with JWT verification disabled (public function)
supabase functions deploy my-function --no-verify-jwtFunction Secrets
# Set secrets for deployed functions
supabase secrets set MY_API_KEY=secret_value
# List secrets
supabase secrets list
# Unset secret
supabase secrets unset MY_API_KEYDatabase Operations
Direct Database Access
# Connect to local database
psql postgresql://postgres:postgres@localhost:54322/postgres
# Execute SQL file
supabase db execute -f path/to/script.sqlSeed Data
# Seed data location
# supabase/seed.sql
# Run seed after reset
supabase db reset # Runs migrations then seed.sqlDatabase Dump/Restore
# Dump schema only
supabase db dump -f schema.sql
# Dump with data
supabase db dump -f backup.sql --data-only
# Restore (use psql directly)
psql $DATABASE_URL < backup.sqlCI/CD Integration
GitHub Actions: Type Generation
name: Generate Supabase Types
on:
push:
paths: ["supabase/migrations/**"]
branches: [main]
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Generate types
run: |
supabase gen types typescript \
--project-id ${{ secrets.SUPABASE_PROJECT_REF }} \
--schema public \
> src/lib/supabase/database.types.ts
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- uses: peter-evans/create-pull-request@v5
with:
commit-message: "chore: update database types"
title: "Update Supabase Database Types"
branch: update-db-typesGitHub Actions: Migration Deploy
name: Deploy Migrations
on:
push:
paths: ["supabase/migrations/**"]
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link project
run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
- name: Push migrations
run: supabase db push
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}GitHub Actions: Edge Function Deploy
name: Deploy Edge Functions
on:
push:
paths: ["supabase/functions/**"]
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Deploy functions
run: supabase functions deploy
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }}Required Secrets
| Secret | Description | Where to Find |
|---|---|---|
SUPABASE_ACCESS_TOKEN | Personal access token | supabase.com/dashboard/account/tokens |
SUPABASE_PROJECT_REF | Project reference ID | Dashboard URL or Project Settings |
Common Commands Reference
| Command | Description |
|---|---|
supabase login | Authenticate CLI |
supabase link --project-ref <ref> | Link to project |
supabase init | Initialize local project |
supabase start | Start local stack |
supabase stop | Stop local stack |
supabase status | Show service status |
supabase gen types typescript | Generate TypeScript types |
supabase migration new <name> | Create migration |
supabase db reset | Reset local database |
supabase db push | Push migrations to remote |
supabase db pull | Pull remote schema |
supabase db diff | Show schema differences |
supabase functions new <name> | Create Edge Function |
supabase functions serve | Serve functions locally |
supabase functions deploy | Deploy functions |
supabase secrets set KEY=value | Set function secrets |
Database Reference
Postgres database patterns, migrations, pgvector, and database functions.
Table of Contents
Migration Conventions
File Naming
supabase/migrations/YYYYMMDDHHmmss_description.sqlExamples:
20241210120000_create_users_table.sql20241210130000_add_profiles_indexes.sql20241210140000_enable_pgvector.sql
Migration Structure
-- 1. Extensions first
create extension if not exists "uuid-ossp";
create extension if not exists "pgcrypto";
-- 2. Types/Enums
create type public.status_type as enum ('draft', 'published', 'archived');
-- 3. Tables
create table public.posts (
id uuid primary key default gen_random_uuid(),
author_id uuid not null references auth.users(id) on delete cascade,
title text not null,
status public.status_type not null default 'draft',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- 4. RLS (mandatory)
alter table public.posts enable row level security;
-- 5. Policies
create policy "Authors view own posts"
on public.posts for select
to authenticated
using ((select auth.uid()) = author_id);
-- 6. Indexes
create index posts_author_id_idx on public.posts(author_id);
create index posts_status_idx on public.posts(status) where status = 'published';
-- 7. Triggers
create trigger posts_updated_at
before update on public.posts
for each row execute function public.handle_updated_at();
-- 8. Comments
comment on table public.posts is 'User blog posts with status workflow';Table Design
Standard Columns
create table public.items (
-- Primary key: UUID preferred
id uuid primary key default gen_random_uuid(),
-- Foreign key to auth.users
user_id uuid not null references auth.users(id) on delete cascade,
-- Timestamps
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- Soft delete (optional)
deleted_at timestamptz
);Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Tables | snake_case, plural | user_profiles |
| Columns | snake_case | created_at |
| Primary keys | id | id uuid |
| Foreign keys | <table>_id | user_id, post_id |
| Indexes | <table>_<column>_idx | posts_user_id_idx |
| Constraints | <table>_<column>_<type> | posts_title_check |
Updated At Trigger
-- Create function once
create or replace function public.handle_updated_at()
returns trigger
language plpgsql
security invoker
set search_path = ''
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- Apply to tables
create trigger items_updated_at
before update on public.items
for each row execute function public.handle_updated_at();Database Functions
Function Template (SECURITY INVOKER)
create or replace function public.get_user_stats(target_user_id uuid)
returns json
language plpgsql
security invoker -- Use caller's permissions, respects RLS
set search_path = '' -- Empty for security
as $$
declare
result json;
begin
select json_build_object(
'post_count', count(*),
'last_post', max(created_at)
) into result
from public.posts
where author_id = target_user_id;
return result;
end;
$$;
comment on function public.get_user_stats is 'Get statistics for a user';RPC Call from Client
const { data, error } = await supabase.rpc("get_user_stats", {
target_user_id: userId,
});Function Security
| Setting | Use Case |
|---|---|
security invoker | Default. Runs with caller's permissions. RLS applies. |
security definer | Runs with function owner's permissions. Bypasses RLS. Use sparingly. |
-- SECURITY DEFINER example (admin operations)
create or replace function public.admin_delete_user(target_id uuid)
returns void
language plpgsql
security definer -- Bypasses RLS
set search_path = ''
as $$
begin
-- Only allow service_role
if current_setting('request.jwt.claims', true)::json->>'role' != 'service_role' then
raise exception 'Unauthorized';
end if;
delete from public.profiles where id = target_id;
end;
$$;Extensions
Enable Extensions
-- In migration file
create extension if not exists "uuid-ossp"; -- UUID generation
create extension if not exists "pgcrypto"; -- Cryptographic functions
create extension if not exists "pg_trgm"; -- Trigram text search
create extension if not exists "vector"; -- pgvector for embeddings
create extension if not exists "postgis"; -- GeospatialExtension Check
-- List enabled extensions
select * from pg_extension;pgvector Setup
Enable Extension
create extension if not exists vector;Create Table with Vector Column
create table public.documents (
id uuid primary key default gen_random_uuid(),
content text not null,
embedding vector(1536), -- OpenAI text-embedding-3-small dimension
metadata jsonb default '{}',
created_at timestamptz not null default now()
);
alter table public.documents enable row level security;Vector Indexes
-- HNSW Index (recommended for most cases)
-- Faster queries, slower index build
create index documents_embedding_idx
on public.documents
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
-- IVFFlat Index (alternative)
-- Faster index build, good for frequent updates
create index documents_embedding_ivf_idx
on public.documents
using ivfflat (embedding vector_cosine_ops)
with (lists = 100);Index Selection
| Index Type | Best For | Trade-offs |
|---|---|---|
| HNSW | Most production use cases | Slower build, more memory |
| IVFFlat | Frequent data updates | Needs retraining periodically |
Semantic Search Function
create or replace function public.match_documents(
query_embedding vector(1536),
match_threshold float default 0.7,
match_count int default 10
)
returns table (
id uuid,
content text,
metadata jsonb,
similarity float
)
language sql
security invoker
set search_path = ''
as $$
select
d.id,
d.content,
d.metadata,
1 - (d.embedding <=> query_embedding) as similarity
from public.documents d
where 1 - (d.embedding <=> query_embedding) > match_threshold
order by d.embedding <=> query_embedding
limit least(match_count, 200);
$$;Hybrid Search (Keyword + Semantic)
-- Add full-text search column
alter table public.documents add column fts tsvector
generated always as (to_tsvector('english', content)) stored;
create index documents_fts_idx on public.documents using gin(fts);
-- Hybrid search function
create or replace function public.hybrid_search(
query_text text,
query_embedding vector(1536),
match_count int default 10,
keyword_weight float default 0.3,
semantic_weight float default 0.7
)
returns table (
id uuid,
content text,
combined_score float
)
language plpgsql
security invoker
set search_path = ''
as $$
begin
return query
with keyword_results as (
select d.id, d.content,
ts_rank(d.fts, websearch_to_tsquery('english', query_text)) as rank
from public.documents d
where d.fts @@ websearch_to_tsquery('english', query_text)
limit match_count * 2
),
semantic_results as (
select d.id, d.content,
1 - (d.embedding <=> query_embedding) as rank
from public.documents d
order by d.embedding <=> query_embedding
limit match_count * 2
)
select
coalesce(k.id, s.id) as id,
coalesce(k.content, s.content) as content,
(coalesce(k.rank, 0) * keyword_weight +
coalesce(s.rank, 0) * semantic_weight) as combined_score
from keyword_results k
full outer join semantic_results s on k.id = s.id
order by combined_score desc
limit match_count;
end;
$$;Indexing Strategies
Common Index Types
-- B-tree (default): Equality and range queries
create index users_email_idx on public.users(email);
-- Partial index: Only index subset of rows
create index posts_published_idx on public.posts(created_at)
where status = 'published';
-- Composite index: Multi-column queries
create index posts_user_status_idx on public.posts(user_id, status);
-- GIN index: JSONB, arrays, full-text
create index items_metadata_idx on public.items using gin(metadata);
-- Unique index
create unique index users_email_unique on public.users(lower(email));RLS-Optimized Indexes
-- Always index foreign keys used in RLS policies
create index items_user_id_idx on public.items(user_id);
-- For team-based access patterns
create index team_members_user_id_idx on public.team_members(user_id);
create index team_members_team_id_idx on public.team_members(team_id);Index Maintenance
-- Check index usage
select
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read
from pg_stat_user_indexes
order by idx_scan desc;
-- Rebuild index (if bloated)
reindex index concurrently public.items_user_id_idx;Edge Functions Reference
Supabase Edge Functions with Deno runtime.
Table of Contents
- Function Structure
- Local Development
- Deployment
- Authentication
- Database Access
- External APIs
- CORS Handling
- Error Handling
Function Structure
Basic Function
// supabase/functions/hello/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
Deno.serve(async (req) => {
const { name } = await req.json();
return new Response(
JSON.stringify({ message: `Hello ${name}!` }),
{ headers: { "Content-Type": "application/json" } }
);
});Directory Structure
supabase/
├── functions/
│ ├── hello/
│ │ └── index.ts
│ ├── process-webhook/
│ │ └── index.ts
│ └── _shared/ # Shared code (not deployed)
│ ├── cors.ts
│ └── supabase.ts
└── config.tomlImport Patterns
// NPM packages
import OpenAI from "npm:openai@4";
import { z } from "npm:zod@3";
import Stripe from "npm:stripe@14";
// JSR packages
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
// Deno standard library
import { serve } from "jsr:@std/http/server";
// Shared local code
import { corsHeaders } from "../_shared/cors.ts";Local Development
Serve Functions Locally
# Start all functions
supabase functions serve
# Start specific function
supabase functions serve hello
# With environment variables
supabase functions serve --env-file ./supabase/.env.localLocal Environment File
# supabase/.env.local
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_test_...Test Locally
# Call function
curl -i --request POST \
http://localhost:54321/functions/v1/hello \
--header "Authorization: Bearer $ANON_KEY" \
--header "Content-Type: application/json" \
--data '{"name":"World"}'Deployment
Deploy Functions
# Deploy all functions
supabase functions deploy
# Deploy specific function
supabase functions deploy hello
# Deploy with JWT verification disabled (public)
supabase functions deploy hello --no-verify-jwtFunction Secrets
# Set secrets
supabase secrets set OPENAI_API_KEY=sk-...
# List secrets
supabase secrets list
# Unset secret
supabase secrets unset OPENAI_API_KEYProduction URL
https://<project-ref>.supabase.co/functions/v1/<function-name>Authentication
Verify JWT (Default)
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
// Get auth header
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// Create client with user's token
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader } } }
);
// Verify user
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response(
JSON.stringify({ error: "Invalid token" }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// User is authenticated
return new Response(
JSON.stringify({ userId: user.id }),
{ headers: { "Content-Type": "application/json" } }
);
});Public Function (No Auth)
# Deploy without JWT verification
supabase functions deploy my-function --no-verify-jwt// Function handles all requests
Deno.serve(async (req) => {
// No auth required
return new Response("Public endpoint");
});Service Role Access
// Use service role for admin operations
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Bypasses RLS
const { data } = await supabaseAdmin.from("users").select("*");Database Access
With User Context (RLS)
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const authHeader = req.headers.get("Authorization")!;
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authHeader } } }
);
// RLS policies apply
const { data, error } = await supabase
.from("items")
.select("*");
return Response.json({ data, error });
});Admin Access (Bypass RLS)
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// No RLS - use carefully!
const { data } = await supabaseAdmin
.from("items")
.insert({ title: "Admin created" });External APIs
OpenAI
import OpenAI from "npm:openai@4";
const openai = new OpenAI({
apiKey: Deno.env.get("OPENAI_API_KEY"),
});
Deno.serve(async (req) => {
const { prompt } = await req.json();
const completion = await openai.chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: prompt }],
});
return Response.json({
response: completion.choices[0].message.content,
});
});Stripe Webhooks
import Stripe from "npm:stripe@14";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
apiVersion: "2023-10-16",
});
const cryptoProvider = Stripe.createSubtleCryptoProvider();
Deno.serve(async (req) => {
const signature = req.headers.get("Stripe-Signature")!;
const body = await req.text();
try {
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider
);
switch (event.type) {
case "checkout.session.completed":
// Handle successful payment
break;
case "customer.subscription.deleted":
// Handle cancellation
break;
}
return new Response(JSON.stringify({ received: true }), {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
});CORS Handling
Shared CORS Headers
// supabase/functions/_shared/cors.ts
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-client-info, apikey",
};Handle Preflight
import { corsHeaders } from "../_shared/cors.ts";
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const data = await req.json();
// Process request...
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});Restrict Origins
const allowedOrigins = [
"https://myapp.com",
"https://staging.myapp.com",
];
function getCorsHeaders(origin: string | null) {
const isAllowed = origin && allowedOrigins.includes(origin);
return {
"Access-Control-Allow-Origin": isAllowed ? origin : allowedOrigins[0],
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
Deno.serve(async (req) => {
const origin = req.headers.get("Origin");
const headers = getCorsHeaders(origin);
if (req.method === "OPTIONS") {
return new Response(null, { headers });
}
// ...
});Error Handling
Structured Error Response
interface ErrorResponse {
error: string;
code?: string;
details?: unknown;
}
function errorResponse(
message: string,
status: number = 500,
code?: string
): Response {
const body: ErrorResponse = { error: message };
if (code) body.code = code;
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
Deno.serve(async (req) => {
try {
const { required_field } = await req.json();
if (!required_field) {
return errorResponse("required_field is missing", 400, "VALIDATION_ERROR");
}
// Process...
return Response.json({ success: true });
} catch (error) {
console.error("Function error:", error);
if (error instanceof SyntaxError) {
return errorResponse("Invalid JSON body", 400, "PARSE_ERROR");
}
return errorResponse("Internal server error", 500, "INTERNAL_ERROR");
}
});Logging
Deno.serve(async (req) => {
const requestId = crypto.randomUUID();
console.log(`[${requestId}] Request started`, {
method: req.method,
url: req.url,
});
try {
// Process request...
console.log(`[${requestId}] Request completed`);
return Response.json({ success: true });
} catch (error) {
console.error(`[${requestId}] Request failed:`, error);
return Response.json({ error: error.message }, { status: 500 });
}
});Scheduled Functions
Cron with pg_cron
-- Enable pg_cron extension
create extension if not exists pg_cron;
-- Schedule function call
select cron.schedule(
'daily-cleanup',
'0 0 * * *', -- Every day at midnight
$$
select
net.http_post(
url := 'https://<project>.supabase.co/functions/v1/cleanup',
headers := '{"Authorization": "Bearer <service-role-key>"}'::jsonb
)
$$
);Using Upstash QStash
// Trigger from application
import { Client } from "@upstash/qstash";
const qstash = new Client({ token: process.env.QSTASH_TOKEN });
await qstash.publishJSON({
url: "https://<project>.supabase.co/functions/v1/process",
body: { taskId: "123" },
delay: 60, // 60 seconds
});Realtime Reference
Supabase Realtime patterns for broadcast, presence, and database changes.
Table of Contents
Channel Types
| Type | Use Case | Latency |
|---|---|---|
| Broadcast | Chat, cursors, notifications | Lowest |
| Presence | Online users, typing indicators | Low |
| postgres_changes | DB sync, audit logs | Higher |
Recommendation: Prefer broadcast for real-time features. Use postgres_changes only when you need DB-triggered events.
Broadcast Channels
Basic Broadcast
const supabase = getBrowserClient();
if (!supabase) return;
// Create channel
const channel = supabase.channel("room:123");
// Subscribe to events
channel.on("broadcast", { event: "message" }, (payload) => {
console.log("Message received:", payload);
});
// Subscribe to channel
await channel.subscribe();
// Send message
channel.send({
type: "broadcast",
event: "message",
payload: { text: "Hello!", userId: "user123" },
});Chat Implementation
"use client";
import { useEffect, useState, useCallback } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
import type { RealtimeChannel } from "@supabase/supabase-js";
interface Message {
id: string;
text: string;
userId: string;
timestamp: number;
}
export function useChat(roomId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const ch = supabase.channel(`chat:${roomId}`, {
config: { private: true },
});
ch.on("broadcast", { event: "message" }, ({ payload }) => {
setMessages((prev) => [...prev, payload as Message]);
});
ch.subscribe();
setChannel(ch);
return () => {
ch.unsubscribe();
};
}, [roomId]);
const sendMessage = useCallback(
(text: string, userId: string) => {
if (!channel) return;
const message: Message = {
id: crypto.randomUUID(),
text,
userId,
timestamp: Date.now(),
};
channel.send({
type: "broadcast",
event: "message",
payload: message,
});
},
[channel]
);
return { messages, sendMessage };
}Cursor Sharing
"use client";
import { useEffect, useState, useRef } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
interface Cursor {
x: number;
y: number;
userId: string;
}
export function useCursors(roomId: string, userId: string) {
const [cursors, setCursors] = useState<Map<string, Cursor>>(new Map());
const channelRef = useRef<RealtimeChannel | null>(null);
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const channel = supabase.channel(`cursors:${roomId}`);
channel.on("broadcast", { event: "cursor" }, ({ payload }) => {
setCursors((prev) => {
const next = new Map(prev);
next.set(payload.userId, payload);
return next;
});
});
channel.subscribe();
channelRef.current = channel;
return () => {
channel.unsubscribe();
};
}, [roomId]);
const updateCursor = (x: number, y: number) => {
channelRef.current?.send({
type: "broadcast",
event: "cursor",
payload: { x, y, userId },
});
};
return { cursors, updateCursor };
}Presence
Track Online Users
"use client";
import { useEffect, useState } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
interface UserPresence {
id: string;
name: string;
status: "online" | "away";
lastSeen: number;
}
export function usePresence(roomId: string, currentUser: UserPresence) {
const [users, setUsers] = useState<UserPresence[]>([]);
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const channel = supabase.channel(`presence:${roomId}`);
channel
.on("presence", { event: "sync" }, () => {
const state = channel.presenceState<UserPresence>();
const presentUsers = Object.values(state)
.flat()
.filter((u) => u.id !== currentUser.id);
setUsers(presentUsers);
})
.on("presence", { event: "join" }, ({ newPresences }) => {
console.log("User joined:", newPresences);
})
.on("presence", { event: "leave" }, ({ leftPresences }) => {
console.log("User left:", leftPresences);
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await channel.track(currentUser);
}
});
return () => {
channel.unsubscribe();
};
}, [roomId, currentUser]);
return users;
}Typing Indicator
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
export function useTypingIndicator(roomId: string, userId: string) {
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const channelRef = useRef<RealtimeChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const channel = supabase.channel(`typing:${roomId}`);
channel.on("broadcast", { event: "typing" }, ({ payload }) => {
if (payload.userId === userId) return;
if (payload.isTyping) {
setTypingUsers((prev) =>
prev.includes(payload.userId) ? prev : [...prev, payload.userId]
);
} else {
setTypingUsers((prev) =>
prev.filter((id) => id !== payload.userId)
);
}
});
channel.subscribe();
channelRef.current = channel;
return () => {
channel.unsubscribe();
};
}, [roomId, userId]);
const setTyping = useCallback(
(isTyping: boolean) => {
channelRef.current?.send({
type: "broadcast",
event: "typing",
payload: { userId, isTyping },
});
// Auto-clear typing after 3 seconds
if (isTyping) {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setTyping(false);
}, 3000);
}
},
[userId]
);
return { typingUsers, setTyping };
}Database Changes
Subscribe to Table Changes
const channel = supabase
.channel("db-changes")
.on(
"postgres_changes",
{
event: "*", // INSERT, UPDATE, DELETE
schema: "public",
table: "messages",
filter: `room_id=eq.${roomId}`,
},
(payload) => {
console.log("Change:", payload);
// payload.eventType: INSERT | UPDATE | DELETE
// payload.new: new row data
// payload.old: old row data (UPDATE, DELETE)
}
)
.subscribe();Event Types
// INSERT only
.on("postgres_changes", { event: "INSERT", ... }, handler)
// UPDATE only
.on("postgres_changes", { event: "UPDATE", ... }, handler)
// DELETE only
.on("postgres_changes", { event: "DELETE", ... }, handler)
// All events
.on("postgres_changes", { event: "*", ... }, handler)Filter Syntax
// Equality
filter: "user_id=eq.123"
// Multiple conditions (comma = AND)
filter: "user_id=eq.123,status=eq.active"Enable Realtime on Table
-- In migration
alter publication supabase_realtime add table public.messages;
-- Or in dashboard: Database → Publications → supabase_realtimeAuthorization
Private Channels
// Client-side
const channel = supabase.channel("private-room", {
config: {
private: true,
},
});RLS for Realtime
-- RLS policies apply to postgres_changes
create policy "Users see own messages"
on public.messages for select
to authenticated
using ((select auth.uid()) = user_id);Broadcast Authorization
For broadcast channels, implement server-side validation:
// Server action to validate room access
async function joinRoom(roomId: string) {
const supabase = await createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
// Check room membership
const { data: membership } = await supabase
.from("room_members")
.select("id")
.eq("room_id", roomId)
.eq("user_id", user.id)
.single();
if (!membership) throw new Error("Not a room member");
return { canJoin: true };
}Connection Management
Connection Status
"use client";
import { useEffect, useState } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";
export function useRealtimeStatus() {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
useEffect(() => {
const supabase = getBrowserClient();
if (!supabase) return;
const channel = supabase.channel("connection-status");
channel.subscribe((status) => {
switch (status) {
case "SUBSCRIBED":
setStatus("connected");
break;
case "CLOSED":
setStatus("disconnected");
break;
case "CHANNEL_ERROR":
setStatus("error");
break;
}
});
return () => {
channel.unsubscribe();
};
}, []);
return status;
}Reconnection
const channel = supabase.channel("my-channel");
channel.subscribe((status, err) => {
if (status === "CHANNEL_ERROR") {
console.error("Channel error:", err);
// Implement exponential backoff
setTimeout(() => {
channel.subscribe();
}, 1000 * Math.random());
}
});Cleanup
// Single channel
await channel.unsubscribe();
// All channels
await supabase.removeAllChannels();Topic Naming Convention
Use consistent topic naming: scope:entity:id
| Pattern | Example | Use Case |
|---|---|---|
room:{id} | room:123 | Chat rooms |
user:{id} | user:abc | User notifications |
trip:{id} | trip:456 | Trip updates |
cursor:{room} | cursor:doc-123 | Cursor sharing |
presence:{room} | presence:room-456 | Online users |
RLS Cookbook
Row Level Security policy patterns for common access control scenarios.
Table of Contents
- RLS Fundamentals
- User-Owned Resources
- Team/Organization Access
- Public Read, Auth Write
- Role-Based Access
- Storage Policies
- Performance Optimization
RLS Fundamentals
Enable RLS (Mandatory)
-- Always enable RLS on every table
alter table public.items enable row level security;Policy Structure
create policy "Policy name"
on public.table_name
for SELECT | INSERT | UPDATE | DELETE | ALL
to authenticated | anon | role_name
using (condition) -- For SELECT, UPDATE, DELETE
with check (condition); -- For INSERT, UPDATEAuth Functions
| Function | Returns | Use |
|---|---|---|
auth.uid() | UUID | Current user's ID |
auth.jwt() | JSON | Full JWT claims |
auth.role() | text | User's role |
auth.email() | text | User's email |
Performance Pattern
-- ALWAYS wrap auth.uid() in subquery for performance
using ((select auth.uid()) = user_id)
-- NOT this (causes repeated function calls)
using (auth.uid() = user_id)User-Owned Resources
Basic CRUD
-- Select: Users view their own items
create policy "Users view own items"
on public.items for select
to authenticated
using ((select auth.uid()) = user_id);
-- Insert: Users create items for themselves
create policy "Users insert own items"
on public.items for insert
to authenticated
with check ((select auth.uid()) = user_id);
-- Update: Users update their own items
create policy "Users update own items"
on public.items for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
-- Delete: Users delete their own items
create policy "Users delete own items"
on public.items for delete
to authenticated
using ((select auth.uid()) = user_id);With Soft Delete
-- Only show non-deleted items
create policy "Users view own active items"
on public.items for select
to authenticated
using (
(select auth.uid()) = user_id
and deleted_at is null
);
-- Soft delete = update, not delete
create policy "Users soft delete own items"
on public.items for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Team/Organization Access
Team Members Table
-- Team members junction table
create table public.team_members (
team_id uuid not null references public.teams(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null default 'member',
primary key (team_id, user_id)
);
alter table public.team_members enable row level security;
-- Index for RLS performance
create index team_members_user_id_idx on public.team_members(user_id);Team Resource Access
-- Users see items belonging to their teams
create policy "Team members view team items"
on public.items for select
to authenticated
using (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
)
);
-- Team members can insert items
create policy "Team members insert team items"
on public.items for insert
to authenticated
with check (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
)
);Role-Based Team Access
-- Only admins can delete team items
create policy "Team admins delete items"
on public.items for delete
to authenticated
using (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
and role = 'admin'
)
);
-- Admins and editors can update
create policy "Team editors update items"
on public.items for update
to authenticated
using (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
and role in ('admin', 'editor')
)
)
with check (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
and role in ('admin', 'editor')
)
);Public Read, Auth Write
Public Content
-- Anyone can read published posts
create policy "Public read published posts"
on public.posts for select
to anon, authenticated
using (status = 'published');
-- Authors can see their own drafts
create policy "Authors view own posts"
on public.posts for select
to authenticated
using ((select auth.uid()) = author_id);
-- Authors can insert
create policy "Authors insert posts"
on public.posts for insert
to authenticated
with check ((select auth.uid()) = author_id);
-- Authors can update own posts
create policy "Authors update own posts"
on public.posts for update
to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);Comments System
-- Anyone can read comments on published posts
create policy "Public read comments"
on public.comments for select
to anon, authenticated
using (
exists (
select 1 from public.posts
where id = comments.post_id
and status = 'published'
)
);
-- Authenticated users can comment
create policy "Auth users insert comments"
on public.comments for insert
to authenticated
with check ((select auth.uid()) = user_id);
-- Users can update own comments
create policy "Users update own comments"
on public.comments for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Role-Based Access
Admin Override
-- Admins can do everything
create policy "Admins full access"
on public.items for all
to authenticated
using (
exists (
select 1 from public.user_roles
where user_id = (select auth.uid())
and role = 'admin'
)
)
with check (
exists (
select 1 from public.user_roles
where user_id = (select auth.uid())
and role = 'admin'
)
);
-- Regular users limited access
create policy "Users own items"
on public.items for all
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Service Role Bypass
-- For operations that need to bypass RLS
-- Use service_role key (server-only!)
const supabase = createClient(url, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});Storage Policies
User Avatar Folder
-- Users upload to their own folder
create policy "Users upload avatars"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'avatars' and
(storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users view their avatars
create policy "Users view own avatars"
on storage.objects for select
to authenticated
using (
bucket_id = 'avatars' and
(storage.foldername(name))[1] = (select auth.uid())::text
);
-- Public avatars (anyone can view)
create policy "Public avatar access"
on storage.objects for select
to anon, authenticated
using (bucket_id = 'avatars');Team Documents
-- Team members can upload
create policy "Team upload documents"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents' and
exists (
select 1 from public.team_members
where team_id = (storage.foldername(name))[1]::uuid
and user_id = (select auth.uid())
)
);
-- Team members can read
create policy "Team read documents"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents' and
exists (
select 1 from public.team_members
where team_id = (storage.foldername(name))[1]::uuid
and user_id = (select auth.uid())
)
);Performance Optimization
Always Index Foreign Keys
-- Essential for RLS performance
create index items_user_id_idx on public.items(user_id);
create index items_team_id_idx on public.items(team_id);
create index team_members_user_id_idx on public.team_members(user_id);
create index team_members_team_id_idx on public.team_members(team_id);Avoid Function Calls in Policies
-- BAD: Function called for every row
using (auth.uid() = user_id)
-- GOOD: Subquery evaluated once
using ((select auth.uid()) = user_id)Use EXISTS for Lookups
-- GOOD: EXISTS with index
using (
exists (
select 1 from public.team_members
where team_id = items.team_id
and user_id = (select auth.uid())
)
)
-- BAD: IN with subquery
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
)Materialized Role Checks
For complex role hierarchies, consider caching:
-- Materialized view for user permissions
create materialized view public.user_permissions as
select
u.id as user_id,
t.id as team_id,
tm.role,
array_agg(distinct p.permission) as permissions
from auth.users u
join public.team_members tm on tm.user_id = u.id
join public.teams t on t.id = tm.team_id
join public.role_permissions rp on rp.role = tm.role
join public.permissions p on p.id = rp.permission_id
group by u.id, t.id, tm.role;
-- Refresh periodically
refresh materialized view public.user_permissions;
-- Use in policies
using (
exists (
select 1 from public.user_permissions
where user_id = (select auth.uid())
and team_id = items.team_id
and 'read' = any(permissions)
)
)Testing Policies
Test as User
-- Set role for testing
set role authenticated;
set request.jwt.claims = '{"sub": "user-uuid-here"}';
-- Test query
select * from public.items;
-- Reset
reset role;Verify Policy Coverage
-- Check which policies exist
select
schemaname,
tablename,
policyname,
permissive,
roles,
cmd,
qual,
with_check
from pg_policies
where schemaname = 'public';Storage Reference
Supabase Storage patterns for file uploads, buckets, and transformations.
Table of Contents
- Bucket Configuration
- Upload Patterns
- Download & Access
- Image Transformations
- Storage RLS
- Resumable Uploads
Bucket Configuration
Create Bucket (Dashboard or Migration)
-- In migration file
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'avatars',
'avatars',
true, -- Public bucket
5242880, -- 5MB limit
array['image/jpeg', 'image/png', 'image/webp']
);
insert into storage.buckets (id, name, public, file_size_limit)
values (
'documents',
'documents',
false, -- Private bucket
52428800 -- 50MB limit
);Bucket Types
| Type | Access | Use Case |
|---|---|---|
| Public | Direct URL, no auth | Profile pictures, public assets |
| Private | Signed URLs required | User documents, sensitive files |
Upload Patterns
Basic Upload
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${userId}/avatar.png`, file, {
cacheControl: "3600",
upsert: true, // Overwrite if exists
});
if (error) throw error;
console.log("Uploaded:", data.path);Upload with Content Type
const { data, error } = await supabase.storage
.from("documents")
.upload(`${userId}/${filename}`, file, {
contentType: file.type,
cacheControl: "3600",
});React Upload Component
"use client";
import { useState } from "react";
import { getBrowserClient } from "@/lib/supabase/client";
export function FileUpload({ userId }: { userId: string }) {
const [uploading, setUploading] = useState(false);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const supabase = getBrowserClient();
if (!supabase) return;
setUploading(true);
const fileExt = file.name.split(".").pop();
const filePath = `${userId}/${crypto.randomUUID()}.${fileExt}`;
const { error } = await supabase.storage
.from("uploads")
.upload(filePath, file);
if (error) {
console.error("Upload error:", error);
}
setUploading(false);
}
return (
<input
type="file"
onChange={handleUpload}
disabled={uploading}
/>
);
}Server Action Upload
// app/actions/upload.ts
"use server";
import { createServerSupabase } from "@/lib/supabase/server";
export async function uploadFile(formData: FormData) {
const supabase = await createServerSupabase();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { error: "Unauthorized" };
}
const file = formData.get("file") as File;
if (!file) {
return { error: "No file provided" };
}
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = `${user.id}/${file.name}`;
const { data, error } = await supabase.storage
.from("uploads")
.upload(filePath, buffer, {
contentType: file.type,
});
if (error) {
return { error: error.message };
}
return { path: data.path };
}Download & Access
Public Bucket URL
// Direct public URL (no auth needed)
const { data } = supabase.storage
.from("avatars")
.getPublicUrl("user123/avatar.png");
console.log(data.publicUrl);
// https://<project>.supabase.co/storage/v1/object/public/avatars/user123/avatar.pngPrivate Bucket - Signed URL
// Time-limited signed URL
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrl("user123/contract.pdf", 3600); // 1 hour
if (data) {
console.log(data.signedUrl);
}Batch Signed URLs
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrls(
["file1.pdf", "file2.pdf", "file3.pdf"],
3600
);
// data = [{ path, signedUrl }, ...]Download File
const { data, error } = await supabase.storage
.from("documents")
.download("user123/contract.pdf");
if (data) {
// data is a Blob
const url = URL.createObjectURL(data);
}Image Transformations
Transform on Public URL
const { data } = supabase.storage
.from("avatars")
.getPublicUrl("user123/photo.jpg", {
transform: {
width: 300,
height: 300,
resize: "cover",
quality: 80,
},
});Transform Options
| Option | Values | Description |
|---|---|---|
width | number | Target width in pixels |
height | number | Target height in pixels |
resize | cover, contain, fill | Resize mode |
quality | 1-100 | JPEG/WebP quality |
format | origin, avif, webp | Output format |
URL Transform Syntax
?width=300&height=200&resize=cover&quality=80&format=webpTransform on Signed URL
const { data } = await supabase.storage
.from("documents")
.createSignedUrl("user123/image.png", 3600, {
transform: {
width: 200,
height: 200,
resize: "contain",
},
});Storage RLS
Enable RLS on Storage
-- RLS is on storage.objects table
alter table storage.objects enable row level security;Policy Patterns
User-Owned Files
-- Users can upload to their own folder
create policy "Users upload own files"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'uploads' and
(storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users can view own files
create policy "Users view own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'uploads' and
(storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users can delete own files
create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'uploads' and
(storage.foldername(name))[1] = (select auth.uid())::text
);Public Read, Auth Write
-- Anyone can read from public bucket
create policy "Public read"
on storage.objects for select
to anon, authenticated
using (bucket_id = 'public-assets');
-- Only authenticated can upload
create policy "Auth users upload"
on storage.objects for insert
to authenticated
with check (bucket_id = 'public-assets');Team-Based Access
-- Team members can access team files
create policy "Team members access"
on storage.objects for select
to authenticated
using (
bucket_id = 'team-files' and
exists (
select 1 from public.team_members
where team_id = (storage.foldername(name))[1]::uuid
and user_id = (select auth.uid())
)
);Storage Helper Functions
-- Extract first folder from path
storage.foldername(name) -- Returns text[]
-- Example: 'user123/docs/file.pdf' → ['user123', 'docs']Resumable Uploads
TUS Protocol Upload
import { TusClient } from "@supabase/tus-client";
async function resumableUpload(
file: File,
bucketId: string,
filePath: string
) {
const supabase = getBrowserClient();
if (!supabase) return;
const { data: { session } } = await supabase.auth.getSession();
if (!session) return;
const tusClient = new TusClient(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/upload/resumable`,
{
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}
);
const upload = tusClient.upload(file, {
bucketId,
objectPath: filePath,
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2);
console.log(`Upload progress: ${percentage}%`);
},
onSuccess: () => {
console.log("Upload complete!");
},
onError: (error) => {
console.error("Upload error:", error);
},
});
return upload;
}Upload Progress Hook
"use client";
import { useState } from "react";
export function useFileUpload() {
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
async function upload(file: File, path: string) {
setUploading(true);
setProgress(0);
// Use XMLHttpRequest for progress
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", () => {
setUploading(false);
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.response));
} else {
reject(new Error(xhr.statusText));
}
});
xhr.addEventListener("error", () => {
setUploading(false);
reject(new Error("Upload failed"));
});
const formData = new FormData();
formData.append("file", file);
xhr.open("POST", `/api/upload?path=${encodeURIComponent(path)}`);
xhr.send(formData);
});
}
return { upload, progress, uploading };
}File Management
List Files
const { data, error } = await supabase.storage
.from("uploads")
.list("user123/", {
limit: 100,
offset: 0,
sortBy: { column: "created_at", order: "desc" },
});
// data = [{ name, id, created_at, updated_at, metadata }, ...]Move/Rename File
const { data, error } = await supabase.storage
.from("uploads")
.move("old/path/file.pdf", "new/path/file.pdf");Copy File
const { data, error } = await supabase.storage
.from("uploads")
.copy("source/file.pdf", "destination/file.pdf");Delete File
const { data, error } = await supabase.storage
.from("uploads")
.remove(["user123/file1.pdf", "user123/file2.pdf"]);Vercel Deployment Reference
Supabase integration with Vercel for production deployments.
Table of Contents
- Vercel Integration Setup
- Environment Variables
- Connection Pooling
- Next.js Configuration
- Production Checklist
- Security Hardening
Vercel Integration Setup
Enable Integration
1. Go to Vercel Dashboard → Project → Settings → Integrations 2. Add Supabase integration 3. Link your Supabase project 4. Environment variables are auto-synced
Manual Setup (Without Integration)
If not using the integration, add variables manually in Vercel Dashboard → Settings → Environment Variables.
Environment Variables
Auto-Synced Variables (With Integration)
The Vercel Supabase integration automatically syncs these variables:
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Project API URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Anonymous/public key |
SUPABASE_URL | Same as public URL (server-side) |
SUPABASE_ANON_KEY | Same as public anon key |
SUPABASE_SERVICE_ROLE_KEY | Service role key (server-only) |
POSTGRES_URL | Pooled connection string |
POSTGRES_PRISMA_URL | Prisma-specific pooled URL |
POSTGRES_URL_NON_POOLING | Direct connection string |
POSTGRES_USER | Database user |
POSTGRES_PASSWORD | Database password |
POSTGRES_DATABASE | Database name |
POSTGRES_HOST | Pooler hostname |
Required .env.local (Local Development)
# Public (exposed to browser)
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Server-only (never expose to client)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Direct database (for migrations)
DATABASE_URL=postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgresConnection Pooling
Supavisor Pooling Modes
| Port | Mode | Use Case |
|---|---|---|
| 6543 | Transaction | Serverless functions, short-lived connections |
| 5432 | Session | Long-running connections, connection persistence |
Serverless Configuration
# Pooled connection for Vercel serverless functions
DATABASE_URL=postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true
# Direct connection for migrations
DIRECT_URL=postgres://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgresPrisma Configuration
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // Pooled (6543)
directUrl = env("DIRECT_URL") // Direct (5432)
}Drizzle Configuration
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Next.js Configuration
Environment Variable Validation
// src/lib/env/server.ts
import { z } from "zod";
const serverEnvSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
});
export const serverEnv = serverEnvSchema.parse(process.env);Dynamic Routes for Auth
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Auth routes must be dynamic (no caching)
experimental: {
// If using PPR
ppr: true,
},
};
export default nextConfig;Auth Routes Configuration
// app/api/auth/[...supabase]/route.ts
// Ensure auth routes are dynamic
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // or "edge"Production Checklist
Pre-Deployment
- [ ] All
NEXT_PUBLIC_*variables are set in Vercel - [ ] Service role key is only in server environment
- [ ] Database URL uses pooled connection (port 6543)
- [ ] Direct URL available for migrations
- [ ] RLS enabled on all tables
- [ ] Auth redirect URLs configured in Supabase Dashboard
Supabase Dashboard Settings
1. Authentication → URL Configuration
- Site URL:
https://your-domain.com - Redirect URLs: Add all valid callback URLs
2. Authentication → Providers
- Configure OAuth providers with production redirect URLs
3. Database → Roles
- Review
authenticatedandanonrole permissions
Vercel Settings
1. Project Settings → Environment Variables
- Verify all Supabase variables are synced
2. Project Settings → Functions
- Set appropriate function timeout (default 10s)
- Configure memory allocation if needed
Security Hardening
Service Role Key Protection
// NEVER do this
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!);
// CORRECT: Use in server-only contexts
import "server-only";
// Only use service role for admin operations
export async function adminOperation() {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// Service role bypasses RLS
}Content Security Policy
// next.config.ts
const securityHeaders = [
{
key: "Content-Security-Policy",
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' https://*.supabase.co wss://*.supabase.co;
img-src 'self' data: https://*.supabase.co;
`.replace(/\n/g, ""),
},
];
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};Rate Limiting Middleware
// middleware.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
});
export async function middleware(request: NextRequest) {
// Rate limit API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
},
}
);
}
}
// Continue with Supabase auth middleware...
}Preview Deployments
Database Branching (Enterprise)
For Supabase Pro/Enterprise with database branching:
# vercel.json
{
"git": {
"deploymentEnabled": {
"main": true,
"preview": true
}
}
}Preview Environment Variables
Set preview-specific environment variables:
| Environment | Variable | Value |
|---|---|---|
| Preview | NEXT_PUBLIC_SUPABASE_URL | Preview project URL |
| Production | NEXT_PUBLIC_SUPABASE_URL | Production project URL |
Monitoring
Vercel Analytics Integration
// app/layout.tsx
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}Error Tracking
// src/lib/supabase/server.ts
import { createServerLogger } from "@/lib/telemetry/logger";
export async function createServerSupabase() {
const logger = createServerLogger("supabase");
try {
// ... client creation
} catch (error) {
logger.error("Failed to create Supabase client", { error });
throw error;
}
}Related skills
FAQ
Should I use getSession() or getUser()?
Use getUser() because it validates the JWT with the auth server; getSession() only reads from the cookie and can be spoofed.
How should RLS reference the user id?
Wrap it in a subquery, using (select auth.uid()) = user_id, to prevent multiple auth.uid() calls.