name: supabase description: Supabase Auth, Database, and SSR patterns. Use when working with Supabase client setup, authentication, Row Level Security, database queries, storage, realtime, or @supabase/ssr middleware.
Supabase Skill
When to Use
- Setting up Supabase client (browser or server)
- Implementing authentication (sign in, sign up, OAuth, session management)
- Writing database queries via Supabase client or
postgrestagged templates - Configuring middleware for session refresh
- Setting up Row Level Security (RLS) policies
- Working with Supabase Storage, Realtime, or Edge Functions
Core Patterns
Client Setup
Browser Client (@supabase/ssr)
"use client";
import { createBrowserClient } from "@supabase/ssr";
export function createSupabaseBrowser() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Server Client (@supabase/ssr + cookies)
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createSupabaseServer() {
const cookieStore = await cookies();
return createServerClient(
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 {
// Safe to ignore in Server Components
}
},
},
}
);
}
Middleware (session refresh)
// src/middleware.ts (or src/proxy.ts for Next.js 16+)
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = 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));
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
await supabase.auth.getUser();
return supabaseResponse;
}
Authentication
Get current user (server)
const supabase = await createSupabaseServer();
const { data: { user }, error } = await supabase.auth.getUser();
// NEVER use getSession() for auth checks — only getUser() is secure
Sign in with OAuth
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "github", // or "google", "discord", etc.
options: { redirectTo: `${origin}/auth/callback` },
});
Sign out
await supabase.auth.signOut();
Auth callback route (exchange code for session)
// src/app/auth/callback/route.ts
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
if (code) {
const supabase = await createSupabaseServer();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}/`);
}
return NextResponse.redirect(`${origin}/auth/sign-in`);
}
Auth UI (pre-built components)
import { Auth } from "@supabase/auth-ui-react";
import { ThemeSupa } from "@supabase/auth-ui-shared";
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
providers={["google", "github"]}
redirectTo={`${origin}/auth/callback`}
/>
Auth state listener (client)
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);
return () => subscription.unsubscribe();
}, [supabase]);
Database Queries
Via Supabase client
// Select
const { data, error } = await supabase.from("posts").select("*").eq("user_id", userId);
// Insert
const { data, error } = await supabase.from("posts").insert({ title, body }).select().single();
// Update
const { data, error } = await supabase.from("posts").update({ title }).eq("id", id).select().single();
// Delete
const { error } = await supabase.from("posts").delete().eq("id", id);
Via postgres tagged templates (direct SQL)
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!, { ssl: "require" });
const rows = await sql`SELECT * FROM posts WHERE user_id = ${userId}`;
Row Level Security (RLS)
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own posts
CREATE POLICY "Users see own posts" ON posts
FOR SELECT USING (auth.uid() = user_id);
-- Policy: users can insert their own posts
CREATE POLICY "Users insert own posts" ON posts
FOR INSERT WITH CHECK (auth.uid() = user_id);
Storage
// Upload
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${userId}/avatar.png`, file);
// Get public URL
const { data } = supabase.storage.from("avatars").getPublicUrl("path/to/file");
// Download
const { data, error } = await supabase.storage.from("avatars").download("path/to/file");
Realtime
const channel = supabase
.channel("posts")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "posts" },
(payload) => console.log("New post:", payload.new)
)
.subscribe();
Environment Variables
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... (JWT anon key, safe for client)
DATABASE_URL=postgresql://... (direct Postgres connection)
NEXT_PUBLIC_*vars are safe for client-side (anon key has RLS restrictions)DATABASE_URLis server-only (bypasses RLS)- Never expose
service_rolekey client-side
Soft Delete + RLS Gotcha
PostgREST applies the SELECT RLS policy to RETURNING * after UPDATE/DELETE. If your SELECT policy hides the updated row (e.g. status != 'deleted'), PostgREST sees 0 rows returned and the client gets empty data with no error — the update committed in the DB but the client thinks nothing happened.
Fix: Use a SECURITY DEFINER RPC for soft deletes. It bypasses RLS; enforce ownership manually inside the function.
CREATE OR REPLACE FUNCTION soft_delete_row(row_id UUID)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
UPDATE my_table SET status = 'deleted', updated_at = now()
WHERE id = row_id AND user_id = auth.uid();
IF NOT FOUND THEN RAISE EXCEPTION 'Not found or not owned'; END IF;
END; $$;
const { error } = await supabase.rpc('soft_delete_row', { row_id: id });
Refs: PostgREST #1844, supabase-js #1941
Anti-Patterns
- Don't use
getSession()for auth checks — usegetUser()which validates with the server - Don't skip middleware — session cookies need refreshing to stay alive
- Don't expose
service_rolekey — it bypasses RLS; useanonkey client-side - Don't create a new Supabase client per request in client components — use
useState(() => createClient())to create once - Don't forget
try/catcharoundcreateSupabaseServer()during static generation when env vars may be absent - Don't use
.update()for soft deletes when RLS hides the result — use SECURITY DEFINER RPC instead (see above)
Integration
- Works with
@supabase/auth-ui-reactfor pre-built auth forms - Use
@supabase/ssr(not@supabase/auth-helpers-nextjswhich is deprecated) - For direct SQL, use
postgrespackage alongside Supabase client - Supabase client respects RLS;
postgresdirect connection bypasses it