Supabase — auth, Postgres, storage
Set up the pieces the app actually needs and prove each one works. Don't scaffold auth for a project that only wants a table.
Argument: $ARGUMENTS is the project path and what's needed. Default to the current directory; if the need isn't stated, ask which of auth / database / storage applies.
This skill assumes Next.js App Router, which is where @supabase/ssr earns its keep. For Vite or plain React, use createClient from @supabase/supabase-js directly and skip the server client and callback route.
Process
1. Detect state
cat package.json | grep -E '"(next|@supabase/supabase-js|@supabase/ssr)"'
grep -l SUPABASE .env.local .env.example 2>/dev/null
ls src/lib/supabase* src/utils/supabase* lib/supabase* 2>/dev/null
ls src/app/auth 2>/dev/null
which supabase || echo "no supabase cli"
2. Project and keys (the human part)
If there's no NEXT_PUBLIC_SUPABASE_URL yet:
- supabase.com → New project. Pick the region closest to the users. It takes about two minutes to provision.
- Settings → API Keys. New projects show a publishable key (
sb_publishable_…) and a secret key (sb_secret_…). Older projects show the legacyanonandservice_roleJWTs; they behave the same way and the code below works with either pair. - Paste into
.env.local; add blanked copies to.env.example:
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... # or the legacy anon key
SUPABASE_SECRET_KEY=sb_secret_... # or the legacy service_role key
The secret / service_role key bypasses every security rule. It goes in server code only, never in anything prefixed NEXT_PUBLIC_, never in git. Check .gitignore covers .env.local before writing the file.
3. Install and create the clients
npm install @supabase/supabase-js @supabase/ssr
src/lib/supabase/client.ts (browser):
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
src/lib/supabase/server.ts (server components, route handlers, server actions):
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
try {
toSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// called from a Server Component; middleware refreshes the session instead
}
},
},
}
);
}
Using the publishable key with a plain createClient on the server loses the logged-in user; every query then runs as anonymous and RLS returns nothing. That's why the server client reads cookies.
4. Auth (if needed)
Callback route src/app/auth/callback/route.ts:
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}${next}`);
}
return NextResponse.redirect(`${origin}/login?error=auth`);
}
Magic link sign-in (client component):
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${location.origin}/auth/callback` },
});
Middleware src/middleware.ts to refresh sessions: use the updateSession helper from the Supabase Next.js docs verbatim; it's about 40 lines and changes with @supabase/ssr versions, so fetch the current one rather than reproducing a stale copy here.
Dashboard steps for the user: Authentication → URL Configuration → add http://localhost:3000/auth/callback and the production URL to Redirect URLs. During development, Authentication → Providers → Email → Confirm email off saves a lot of inbox-checking. For production auth emails, set Resend as the custom SMTP under Authentication → SMTP Settings (run /resend first); Supabase's default sender is rate-limited to a handful an hour and lands in spam.
5. Database (if needed)
Write the schema as SQL the user can paste into SQL Editor, or run with the CLI if supabase is installed and linked. Shape it to the app; this is the pattern:
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users on delete cascade,
title text not null,
body text,
created_at timestamptz default now()
);
alter table notes enable row level security;
create policy "read own" on notes for select using (auth.uid() = user_id);
create policy "insert own" on notes for insert with check (auth.uid() = user_id);
create policy "update own" on notes for update using (auth.uid() = user_id);
create policy "delete own" on notes for delete using (auth.uid() = user_id);
RLS on, with policies, every time. A table without RLS is readable by anyone with the publishable key, which is everyone who loads the site. For public read-only data, add create policy "public read" on t for select using (true) deliberately rather than leaving RLS off.
Generate types if the CLI is available:
supabase gen types typescript --project-id <ref> > src/lib/supabase/types.ts
6. Storage (if needed)
Dashboard: Storage → New bucket. Public bucket for avatars and images that appear in the UI; private bucket plus signed URLs for anything user-specific. Upload pattern:
const { data, error } = await supabase.storage
.from('uploads')
.upload(`${user.id}/${file.name}`, file, { upsert: true });
Storage has its own RLS on storage.objects; add a policy scoping writes to auth.uid()::text = (storage.foldername(name))[1].
7. Verify
Start the dev server and hit a real query. Simplest is a temporary server component or a route:
const supabase = await createClient();
const { data, error, count } = await supabase.from('notes').select('*', { count: 'exact', head: true });
Expect error: null. If auth is set up, sign in with a magic link, land on the callback, and confirm supabase.auth.getClaims() returns the user's claims (it validates the JWT locally; getUser() also works but makes a network call every time). Remove the test route afterwards.
8. Report
Supabase
- Project: xxxx.supabase.co (region: eu-west-2)
- Clients: src/lib/supabase/{client,server}.ts
- Auth: magic link + /auth/callback · redirect URLs added · SMTP → Resend
- DB: notes table, RLS on, 4 policies
- Storage: bucket "uploads" (private)
- Verify: select on notes → error null · sign-in round trip OK
- Env: 3 vars local · add all three to Vercel (the NEXT_PUBLIC_ ones ship to the browser by design)
Gotchas
- RLS failures are silent. Empty array, no error. Policies are the first thing to check when a query "returns nothing".
- Free projects pause after 7 days idle. Dashboard login wakes them. Pro ($25/month) doesn't pause and adds daily backups.
exchangeCodeForSessionneeds PKCE, which@supabase/ssrenables by default. If the callback loops, the redirect URL is usually missing from URL Configuration.- Firebase is the alternative and its NoSQL model is hard to migrate away from. Supabase is real Postgres; you can always take the data elsewhere.