From tdd-dev-workflow
This skill should be used when the user asks about "Supabase Auth with Next.js", "authentication Next.js", "login Supabase", "auth middleware Next.js", "protected routes", "auth callback", "Supabase SSR auth", "session management Next.js", "@supabase/ssr", or needs to integrate Supabase authentication in a Next.js App Router application.
How this skill is triggered — by the user, by Claude, or both
Slash command
/tdd-dev-workflow:nextjs-supabase-authThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use `@supabase/ssr` for all Supabase Auth in Next.js. The deprecated `@supabase/auth-helpers-nextjs` package must not be used. Handle token refresh in middleware, not in client components. Validate sessions server-side with `getUser()`, never `getSession()`.
Use @supabase/ssr for all Supabase Auth in Next.js. The deprecated @supabase/auth-helpers-nextjs package must not be used. Handle token refresh in middleware, not in client components. Validate sessions server-side with getUser(), never getSession().
Three distinct Supabase clients are required. Each serves a different runtime context.
Create lib/supabase/client.ts for Client Components:
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
Use this client in 'use client' components only. It reads and writes cookies automatically via the browser.
Create lib/supabase/server.ts for Server Components, Server Actions, and Route Handlers:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = async () => {
const cookieStore = await cookies()
return createServerClient(
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)
})
},
},
}
)
}
Create lib/supabase/middleware.ts for the Next.js middleware layer:
import { createServerClient } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'
export const updateSession = async (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 }) => {
request.cookies.set(name, value)
response.cookies.set(name, value, options)
})
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users to login
if (!user && !request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Redirect authenticated users away from login
if (user && request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return response
}
This client sets cookies on both the request and response objects. Setting on the request ensures downstream Server Components read the refreshed token. Setting on the response sends the updated cookie to the browser.
Create middleware.ts at the project root:
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)'],
}
getUser(), which triggers cookie renewal./login./login to /dashboard.Create app/auth/callback/route.ts to handle OAuth and Magic Link redirects:
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') ?? '/dashboard'
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_callback_failed`)
}
Create app/login/actions.ts:
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export async function signIn(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
if (error) redirect('/login?error=invalid_credentials')
redirect('/dashboard')
}
export async function signUp(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signUp({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
if (error) redirect('/login?error=signup_failed')
redirect('/login?message=check_email')
}
Always call redirect() after any auth state change in Server Actions. The redirect terminates the action and prevents stale state.
For OAuth providers, use the browser client:
'use client'
import { createClient } from '@/lib/supabase/client'
export function OAuthButton() {
const handleLogin = async () => {
const supabase = createClient()
await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/callback` },
})
}
return <button onClick={handleLogin}>Sign in with Google</button>
}
Protect pages by checking the user in Server Components:
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
return <div>Welcome, {user.email}</div>
}
Use getUser() which validates the JWT with the Supabase Auth server. Never use getSession() in Server Components -- it only reads the JWT locally without validation and can be spoofed.
Apply auth checks in a layout to protect an entire route group:
// app/(protected)/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
return <>{children}</>
}
Create an auth provider for client-side auth state:
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'
const AuthContext = createContext<{ user: User | null }>({ user: null })
export function AuthProvider({ children, initialUser }: {
children: React.ReactNode
initialUser: User | null
}) {
const [user, setUser] = useState<User | null>(initialUser)
useEffect(() => {
const supabase = createClient()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => setUser(session?.user ?? null)
)
return () => subscription.unsubscribe()
}, [])
return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
Pass initialUser from a Server Component to avoid a loading flash. Wrap the app in root layout.
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
getSession() for security checks | Reads JWT locally without validation; can be tampered | Use getUser() which validates with Supabase |
| Storing auth state in localStorage | Bypasses cookie-based session; breaks SSR | Let @supabase/ssr manage cookies |
| Skipping token refresh in middleware | Sessions expire silently; users get 401s | Always call getUser() in middleware |
Using @supabase/auth-helpers-nextjs | Deprecated; incompatible with App Router patterns | Use @supabase/ssr |
| Auth in API Routes instead of Server Actions | Adds unnecessary API layer; loses RSC benefits | Use Server Actions for auth mutations |
| Client-side redirect after Server Action | Creates flash of unauthorized content | Use redirect() inside the Server Action |
Combine Supabase Auth with RLS and user metadata to enforce role-based access:
// Server Action to check admin role
export async function requireAdmin() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
if (user.app_metadata?.role !== 'admin') redirect('/unauthorized')
return user
}
Set roles via Supabase Admin API or database triggers -- never from client-side code. Store roles in app_metadata (server-writable only), not user_metadata (client-writable).
RLS policies reference the authenticated user automatically:
-- Allow users to read only their own data
CREATE POLICY "Users read own data" ON profiles
FOR SELECT USING (auth.uid() = id);
-- Allow admins to read all data
CREATE POLICY "Admins read all" ON profiles
FOR SELECT USING (
(SELECT raw_app_meta_data->>'role' FROM auth.users WHERE id = auth.uid()) = 'admin'
);
'use server'
export async function resetPassword(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.resetPasswordForEmail(
formData.get('email') as string,
{ redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/reset-password` }
)
if (error) redirect('/login?error=reset_failed')
redirect('/login?message=check_email_for_reset')
}
After the user clicks the email link, they arrive at the callback route with an auth code. The callback exchanges the code for a session, then redirects to the password update form:
// app/reset-password/page.tsx
export default async function ResetPasswordPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
return <UpdatePasswordForm />
}
Required in .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Both are prefixed with NEXT_PUBLIC_ because the browser client needs them. The anon key is safe to expose -- RLS policies enforce security, not the key.
| Concern | Location | Client |
|---|---|---|
| Token refresh | middleware.ts | Middleware client |
| OAuth/Magic Link callback | app/auth/callback/route.ts | Server client |
| Sign in/up mutations | Server Actions | Server client |
| OAuth initiation | Client Component | Browser client |
| Route protection (SSR) | Server Component or Layout | Server client |
| Real-time auth state | Client Component + Provider | Browser client |
| Security validation | Anywhere server-side | getUser() only |
| Skill | Reason |
|---|---|
nextjs-patterns | App Router architecture, Server Components, middleware, Server Actions, route groups, and layouts |
supabase-patterns | Supabase client initialization, Auth API surface, Row Level Security integration, and database access patterns |
react-best-practices (transitive) | Component architecture, hooks, context, and rendering patterns (via nextjs-patterns) |
typescript-pro (transitive) | Type safety, generics, and strict configuration (via nextjs-patterns, supabase-patterns) |
postgres-best-practices (transitive) | Database schema design and query patterns underlying RLS (via supabase-patterns) |
tailwind-patterns (transitive) | Styling patterns for auth UI components (via nextjs-patterns) |
None -- this is a top-layer integration skill.
npx claudepluginhub inteligentsensingsolutions/tdd-dev-workflow --plugin tdd-dev-workflowGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.