Supabase Authentication
Cookie-based authentication with SSR support.
When to Use This Skill
-
Need authentication without rolling your own
-
Building a Next.js app with SSR
-
Want email/password + social auth options
-
Need automatic session refresh
Core Concepts
-
Browser client - For client components
-
Server client - For API routes and server components
-
Cookie-based sessions - Automatic refresh via middleware
-
User profiles - Extended user data in your database
TypeScript Implementation
Browser Client
// lib/supabase.ts import { createBrowserClient } from '@supabase/ssr';
export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); }
Server Client
// lib/supabase-server.ts import { createServerClient, type CookieOptions } from '@supabase/ssr'; import { cookies } from 'next/headers';
export async function createServerSupabaseClient() { 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: { name: string; value: string; options: CookieOptions }[]) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch { // Called from Server Component - ignore } }, }, } ); }
Login Page
// app/login/page.tsx 'use client';
import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { createClient } from '@/lib/supabase';
export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const searchParams = useSearchParams(); const redirectTo = searchParams.get('redirectTo') || '/dashboard'; const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true);
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
router.push(redirectTo);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
return ( <form onSubmit={handleLogin} className="space-y-4 max-w-md mx-auto mt-20"> <h1 className="text-2xl font-bold">Sign In</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
className="w-full px-4 py-2 border rounded"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
className="w-full px-4 py-2 border rounded"
/>
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
<p className="text-center text-sm">
Don't have an account? <a href="/signup" className="text-blue-600">Sign up</a>
</p>
</form>
); }
Signup Page
// app/signup/page.tsx 'use client';
import { useState } from 'react'; import { createClient } from '@/lib/supabase';
export default function SignupPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const [error, setError] = useState<string | null>(null); const [success, setSuccess] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleSignup = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true);
try {
const supabase = createClient();
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
options: {
data: { display_name: name },
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (signUpError) throw signUpError;
if (data?.user?.identities?.length === 0) {
setError('This email is already registered.');
return;
}
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Signup failed');
} finally {
setIsLoading(false);
}
};
if (success) { return ( <div className="text-center mt-20"> <h1 className="text-2xl font-bold mb-4">Check your email</h1> <p>We sent a confirmation link to {email}</p> </div> ); }
return ( <form onSubmit={handleSignup} className="space-y-4 max-w-md mx-auto mt-20"> <h1 className="text-2xl font-bold">Create Account</h1>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
className="w-full px-4 py-2 border rounded"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
className="w-full px-4 py-2 border rounded"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password (min 6 characters)"
minLength={6}
required
className="w-full px-4 py-2 border rounded"
/>
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
); }
Auth Callback Route
// app/auth/callback/route.ts import { createServerClient } from '@supabase/ssr'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server';
export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get('code'); const next = searchParams.get('next') ?? '/dashboard';
if (code) { const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(${origin}/login?error=auth_callback_error);
}
useUser Hook
// hooks/useUser.ts 'use client';
import { useEffect, useState, useCallback } from 'react'; import { createClient } from '@/lib/supabase'; import type { User } from '@supabase/supabase-js';
interface UserProfile { id: string; display_name: string | null; subscription_tier: 'free' | 'pro'; }
export function useUser() { const [user, setUser] = useState<User | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null); const [isLoading, setIsLoading] = useState(true); const supabase = createClient();
const fetchProfile = useCallback(async (userId: string) => { const { data } = await supabase .from('user_profiles') .select('*') .eq('id', userId) .single(); return data as UserProfile | null; }, [supabase]);
const signOut = useCallback(async () => { await supabase.auth.signOut(); setUser(null); setProfile(null); }, [supabase]);
useEffect(() => { const getSession = async () => { const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
setUser(session.user);
const profileData = await fetchProfile(session.user.id);
setProfile(profileData);
}
setIsLoading(false);
};
getSession();
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
setUser(session.user);
const profileData = await fetchProfile(session.user.id);
setProfile(profileData);
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
}
}
);
return () => subscription.unsubscribe();
}, [supabase, fetchProfile]);
return { user, profile, tier: profile?.subscription_tier ?? 'free', isLoading, signOut, }; }
Database Migration
-- migrations/001_user_profiles.sql
CREATE TABLE user_profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, display_name VARCHAR(255), subscription_tier VARCHAR(20) NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );
-- Auto-create profile on signup CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO user_profiles (id, display_name) VALUES (NEW.id, NEW.raw_user_meta_data->>'display_name'); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- RLS ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile" ON user_profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON user_profiles FOR UPDATE USING (auth.uid() = id);
Best Practices
-
Use SSR client - Server components need cookie access
-
Refresh in middleware - Keep sessions alive automatically
-
Auto-create profiles - Database trigger on signup
-
Enable RLS - Row-level security on all user tables
-
Handle email confirmation - Check for empty identities array
Common Mistakes
-
Using browser client in server components
-
Not refreshing session in middleware
-
Missing RLS policies on user data
-
Not handling email confirmation flow
-
Forgetting to call router.refresh() after login
Related Skills
-
Middleware Protection
-
Row Level Security
-
JWT Auth