Supabase Auth with Next.js 16 β The Complete 2026 Guide
- @supabase/ssr is mandatory for App Router β provides cookie-based sessions across client and server
- Middleware is non-negotiable β it refreshes access tokens on every request
- Always use getUser() on the server β getSession() reads unvalidated cookies
- @supabase/ssr is the only correct package for Next.js App Router β supabase-js alone does not handle cookies
- Middleware refreshes the access token on every request β without it, sessions expire silently
- Server Components, Server Actions, and Route Handlers each need a differently configured Supabase client
- PKCE flow is the default and automatic in @supabase/ssr β it prevents auth code interception attacks
- Never store tokens in localStorage β cookie-based sessions are the only secure pattern in App Router
- Biggest mistake: using the browser client on the server β it leaks credentials and breaks SSR
Session not persisting across requests
ls -la middleware.tscat middleware.ts | head -50Cookies not being set after login
curl -s -I http://localhost:3000 | grep -i set-cookiecat .env.local | grep SUPABASEgetUser() returns null in Server Component
grep -rn 'createServerClient' app/ --include='*.tsx' --include='*.ts' | head -10grep -rn 'cookies()' app/ --include='*.tsx' --include='*.ts' | head -10OAuth provider returns 403 or access_denied
cat .env.local | grep -i 'SUPABASE_URL\|SUPABASE_KEY\|SITE_URL'curl -s ${NEXT_PUBLIC_SUPABASE_URL}/auth/v1/healthProduction Incident
Production Debug GuideDiagnose session, authentication, and token issues
Supabase Auth provides email/password, OAuth, magic link, and phone authentication backed by GoTrue. Integrating it with Next.js App Router requires a specific client configuration that differs from the Pages Router approach. The core dependency is @supabase/ssr β it manages cookie-based sessions and provides factory functions for browser, server, and middleware contexts.
The three most common integration failures are: using supabase-js directly without @supabase/ssr (sessions do not persist across requests), skipping middleware (access tokens expire and users are silently logged out), and using the same client configuration in Server Components and Client Components (cookie handling breaks).
This guide covers the complete integration: client setup, middleware configuration, Server Actions for auth flows, route protection, OAuth providers, and the production failure patterns that cause outages.
Package Setup: @supabase/ssr is Mandatory
The correct package for Next.js App Router is @supabase/ssr β not @supabase/supabase-js used alone. The ssr package provides three factory functions: createBrowserClient for Client Components, createServerClient for Server Components and Route Handlers, and the same createServerClient used in middleware. It manages cookie-based sessions automatically.
The supabase-js package alone stores sessions in localStorage by default. This breaks SSR because Server Components cannot access localStorage β they have no browser context. The ssr package solves this by using cookies, which are available on both client and server.
Install both packages: @supabase/ssr and @supabase/supabase-js (ssr depends on it). The supabase-js package is a peer dependency β you do not import from it directly in App Router code.
// ============================================ // Package Installation and Environment Setup // ============================================ // npm install @supabase/ssr @supabase/supabase-js // ---- .env.local ---- // NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co // NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... // SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... // ---- Supabase URL Configuration (Dashboard) ---- // Site URL: https://your-domain.com // Redirect URLs: // https://your-domain.com/auth/callback // https://your-domain.com/auth/callback?next=/dashboard // http://localhost:3000/auth/callback (for local dev) // ============================================ // Three Client Factories β One Per Context // ============================================ // Each context needs a differently configured Supabase client. // Using the wrong client in the wrong context causes: // - Session loss // - Cookie handling failures // - SSR hydration mismatches // ---- Factory 1: Browser Client (Client Components) ---- // File: lib/supabase/client.ts // Used in: Client Components with 'use client' // Handles: Cookie read/write on the browser side import { createBrowserClient } from '@supabase/ssr' export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) } // ---- Factory 2: Server Client (Server Components, Route Handlers) ---- // File: lib/supabase/server.ts // Used in: Server Components, Route Handlers, Server Actions // Handles: Cookie read/write via next/headers cookies() 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_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ) } catch { // setAll is called from Server Components where // cookies cannot be modified. This is expected β // middleware handles cookie updates. } }, }, } ) } // ---- Factory 3: Middleware Client ---- // File: middleware.ts (project root) // Used in: Next.js middleware // Handles: Token refresh on every request import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function updateSession(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) ) }, }, } ) // This is the critical call β it refreshes the token if expired // Do NOT remove this line const { data: { user } } = await supabase.auth.getUser() // Redirect unauthenticated users away from protected routes if ( !user && !request.nextUrl.pathname.startsWith('/login') && !request.nextUrl.pathname.startsWith('/auth') ) { const url = request.nextUrl.clone() url.pathname = '/login' return NextResponse.redirect(url) } return supabaseResponse } export const config = { matcher: [ /* * Match all request paths except: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * - public folder */ '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], }
- Browser Client (createBrowserClient) β Client Components, reads/writes cookies via document.cookie
- Server Client (createServerClient + cookies()) β Server Components, Route Handlers, reads cookies via next/headers
- Middleware Client (createServerClient) β middleware.ts, refreshes tokens on every request
- Using the browser client on the server breaks SSR β it cannot access document.cookie
- Using the server client in Client Components causes hydration mismatches β it accesses server-only APIs
Middleware: The Non-Negotiable Token Refresh Layer
Middleware runs on every request before the page renders. Its primary job in Supabase Auth is to refresh the access token when it is near expiry. Without middleware, the access token expires after 1 hour (default) and the user is silently logged out.
The middleware calls supabase.auth.getUser() β this is the trigger for token refresh. If the access token is expired, getUser() uses the refresh token (stored in cookies) to obtain a new access token. The new tokens are written back to cookies via the setAll callback.
The matcher configuration is critical. Without it, middleware runs on static assets (_next/static, images, favicon) β this adds unnecessary Supabase API calls and increases latency. The matcher should exclude static files and public assets.
// ============================================ // middleware.ts β Token Refresh and Route Protection // ============================================ // File: middleware.ts (MUST be at project root) // This file runs on every matched request β before page render 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) ) }, }, } ) // ---- Critical: getUser() triggers token refresh ---- // This is NOT just a user check β it refreshes expired tokens // Removing this line causes sessions to expire after 1 hour const { data: { user }, } = await supabase.auth.getUser() // ---- Route Protection Logic ---- // Define which routes require authentication const isAuthRoute = request.nextUrl.pathname.startsWith('/auth') const isLoginRoute = request.nextUrl.pathname === '/login' const isPublicRoute = request.nextUrl.pathname === '/' || request.nextUrl.pathname.startsWith('/pricing') || request.nextUrl.pathname.startsWith('/about') // Redirect unauthenticated users to login if (!user && !isAuthRoute && !isLoginRoute && !isPublicRoute) { const url = request.nextUrl.clone() url.pathname = '/login' url.searchParams.set('redirectedFrom', request.nextUrl.pathname) return NextResponse.redirect(url) } // Redirect authenticated users away from login page if (user && isLoginRoute) { const url = request.nextUrl.clone() url.pathname = '/dashboard' return NextResponse.redirect(url) } return supabaseResponse } // ---- Matcher: Exclude static assets ---- // Without this, middleware runs on every static file request // This adds latency and unnecessary Supabase API calls export const config = { matcher: [ /* * Match all request paths except: * - api routes that handle their own auth * - _next/static (static files) * - _next/image (image optimization) * - favicon.ico * - Static file extensions */ '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } // ============================================ // Alternative: Role-Based Route Protection // ============================================ // If you need role-based access control in middleware, // fetch the user profile after getUser() export async function middlewareWithRoles(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) ) }, }, } ) const { data: { user } } = await supabase.auth.getUser() // Admin-only routes if (request.nextUrl.pathname.startsWith('/admin')) { if (!user) { return NextResponse.redirect(new URL('/login', request.url)) } // Fetch user role from your users table const { data: profile } = await supabase .from('profiles') .select('role') .eq('id', user.id) .single() if (profile?.role !== 'admin') { return NextResponse.redirect(new URL('/dashboard', request.url)) } } return supabaseResponse }
Server Actions: Sign Up, Sign In, Sign Out, OAuth
Server Actions handle authentication flows on the server. They receive form data, call Supabase Auth methods, and redirect the user. The key pattern: Server Actions use the server client (createServerClient with cookies()), not the browser client.
Each auth flow has specific requirements: sign up requires email confirmation (unless disabled), sign in sets cookies via the response, sign out clears cookies, and OAuth requires a callback route that exchanges the auth code for a session.
The redirect URL for OAuth must be registered in the Supabase Dashboard. Mismatches cause 403 errors that are difficult to debug because the error occurs at the OAuth provider level, not in your code.
// ============================================ // Server Actions for Authentication // ============================================ // File: app/auth/actions.ts // All auth flows run on the server β never expose keys to the client 'use server' import { redirect } from 'next/navigation' import { headers } from 'next/headers' import { createClient } from '@/lib/supabase/server' // ---- Sign Up ---- // Creates a new user with email and password // Supabase sends a confirmation email by default // User must click the link to verify their email export async function signUp(formData: FormData) { const supabase = await createClient() const email = formData.get('email') as string const password = formData.get('password') as string const name = formData.get('name') as string const { error } = await supabase.auth.signUp({ email, password, options: { data: { // Custom user metadata stored in auth.users.raw_user_meta_data full_name: name, }, // URL the user is redirected to after clicking the confirmation email emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, }, }) if (error) { return { error: error.message } } // Show confirmation message β user must verify email redirect('/auth/check-email') } // ---- Sign In ---- // Authenticates with email and password // Sets session cookies automatically via @supabase/ssr export async function signIn(formData: FormData) { const supabase = await createClient() const email = formData.get('email') as string const password = formData.get('password') as string const { error } = await supabase.auth.signInWithPassword({ email, password, }) if (error) { return { error: error.message } } redirect('/dashboard') } // ---- Sign In with OAuth ---- // Initiates the OAuth flow (Google, GitHub, etc.) // User is redirected to the provider, then back to the callback route export async function signInWithOAuth(provider: 'google' | 'github' | 'azure') { const supabase = await createClient() const headersList = await headers() const origin = headersList.get('origin') const { data, error } = await supabase.auth.signInWithOAuth({ provider, options: { // This URL must be registered in Supabase Dashboard // Auth > URL Configuration > Redirect URLs redirectTo: `${origin}/auth/callback`, }, }) if (error) { redirect('/login?error=oauth_failed') } // Supabase returns the OAuth provider URL β redirect the user redirect(data.url) } // ---- Sign Out ---- // Clears session cookies and redirects to login export async function signOut() { const supabase = await createClient() await supabase.auth.signOut() redirect('/login') } // ---- Forgot Password ---- // Sends a password reset email export async function forgotPassword(formData: FormData) { const supabase = await createClient() const email = formData.get('email') as string const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`, }) if (error) { return { error: error.message } } return { success: 'Check your email for the reset link' } } // ---- Update Password ---- // Called from the reset password page after user clicks the email link export async function updatePassword(formData: FormData) { const supabase = await createClient() const password = formData.get('password') as string const { error } = await supabase.auth.updateUser({ password, }) if (error) { return { error: error.message } } redirect('/dashboard') } // ---- OAuth Callback Route ---- // File: app/auth/callback/route.ts // Exchanges the auth code for a session import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' export async function GET(request: NextRequest) { const { searchParams } = 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) { const forwardedHost = request.headers.get('x-forwarded-host') const isLocalEnv = process.env.NODE_ENV === 'development' if (isLocalEnv) { return NextResponse.redirect(`http://localhost:3000${next}`) } else if (forwardedHost) { return NextResponse.redirect(`https://${forwardedHost}${next}`) } else { return NextResponse.redirect(`${process.env.NEXT_PUBLIC_SITE_URL}${next}`) } } } // Auth code exchange failed return NextResponse.redirect(`${new URL(request.url).origin}/login?error=auth_callback_failed`) } // ---- Magic Link ---- // Sends a one-click sign-in link export async function signInWithMagicLink(formData: FormData) { const supabase = await createClient() const email = formData.get('email') as string const { error } = await supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, }, }) if (error) { return { error: error.message } } return { success: 'Check your email for the sign-in link' } }
- Sign up creates the user β email confirmation is required by default (can be disabled in Dashboard)
- Sign in sets session cookies β @supabase/ssr handles cookie write automatically
- OAuth initiates a redirect β the callback route exchanges the code for a session
- Sign out clears cookies β always redirect after signOut() to avoid stale UI
- All auth errors return a message string β handle them in the form component
Route Protection: Server Components and Layout Guards
Route protection happens at two levels: middleware (runs before render, catches all requests) and Server Components (runs during render, provides user data to the page). Middleware is for enforcement β it redirects unauthenticated users. Server Components are for data β they fetch the user and pass it to the UI.
The pattern: middleware protects routes at the network level (redirects before any code runs). Server Components protect at the render level (fetch user, show/hide content). Both use getUser() β this is the Supabase-recommended method for server-side auth checks because it validates the JWT with the Supabase Auth server.
// ============================================ // Route Protection Patterns // ============================================ // ---- Pattern 1: Server Component Auth Check ---- // File: app/dashboard/page.tsx // Fetches the user in a Server Component β passes to children import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' export default async function DashboardPage() { const supabase = await createClient() // getUser() validates the JWT with Supabase Auth server // Do NOT use getSession() for server-side auth β it reads // from cookies without validation const { data: { user }, error } = await supabase.auth.getUser() if (error || !user) { redirect('/login') } // Fetch user-specific data const { data: profile } = await supabase .from('profiles') .select('*') .eq('id', user.id) .single() return ( <div> <h1>Welcome, {profile?.full_name ?? user.email}</h1> <p>Email: {user.email}</p> <p>Member since: {new Date(user.created_at).toLocaleDateString()}</p> </div> ) } // ---- Pattern 2: Layout Guard ---- // File: app/(protected)/layout.tsx // Protects all routes under the (protected) route group import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { Sidebar } from '@/components/sidebar' 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 ( <div className="flex min-h-screen"> <Sidebar user={user} /> <main className="flex-1 p-6"> {children} </main> </div> ) } // ---- Pattern 3: Client Component with User Context ---- // File: components/user-provider.tsx // Passes server-fetched user to Client Components 'use client' import { createContext, useContext } from 'react' import type { User } from '@supabase/supabase-js' const UserContext = createContext<User | null>(null) export function UserProvider({ user, children, }: { user: User children: React.ReactNode }) { return ( <UserContext.Provider value={user}> {children} </UserContext.Provider> ) } export function useUser() { const user = useContext(UserContext) if (!user) { throw new Error('useUser must be used within UserProvider') } return user } // ---- Usage in Layout ---- // File: app/(protected)/layout.tsx (updated) import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { UserProvider } from '@/components/user-provider' 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 ( <UserProvider user={user}> {children} </UserProvider> ) } // ---- Client Component consuming the user ---- // File: components/user-avatar.tsx 'use client' import { useUser } from '@/components/user-provider' export function UserAvatar() { const user = useUser() return ( <div className="flex items-center gap-2"> <div className="h-8 w-8 rounded-full bg-primary"> {user.email?.charAt(0).toUpperCase()} </div> <span className="text-sm">{user.email}</span> </div> ) }
Row Level Security: Server-Side Authorization
Supabase uses Row Level Security (RLS) to enforce authorization at the database level. RLS policies control which rows a user can read, insert, update, or delete. The auth.uid() function returns the authenticated user's ID β it is available in RLS policies because Supabase sets it from the JWT.
RLS is the authorization layer β it works independently of the authentication layer. Even if a user is authenticated, they cannot access rows that RLS policies deny. This is defense in depth: even if your application code has a bug, the database enforces access control.
The critical rule: NEVER disable RLS on tables that store user data. If RLS is disabled, any authenticated user can read or modify any row β including other users' data.
-- ============================================ -- Row Level Security (RLS) Policies -- ============================================ -- RLS enforces authorization at the database level -- auth.uid() returns the authenticated user's ID from the JWT -- ---- Enable RLS on a table ---- -- Without this, RLS policies are ignored ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -- ---- Policy: Users can read their own profile ---- CREATE POLICY "Users can read own profile" ON public.profiles FOR SELECT USING (auth.uid() = id); -- ---- Policy: Users can update their own profile ---- CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id); -- ---- Policy: Users can insert their own profile ---- -- Typically called via a trigger after user signs up CREATE POLICY "Users can insert own profile" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id); -- ---- Policy: Users cannot delete profiles ---- -- No DELETE policy = deletion is denied by default -- ---- Auto-create profile on signup ---- -- Trigger that creates a profile row when a user signs up CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN INSERT INTO public.profiles (id, full_name, avatar_url) VALUES ( NEW.id, NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'avatar_url' ); RETURN NEW; END; $$; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); -- ---- Multi-tenant: Team-based access ---- -- Users can only access data from their team CREATE TABLE public.projects ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, team_id UUID NOT NULL REFERENCES public.teams(id), name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY; -- Policy: Users can read projects from their team CREATE POLICY "Team members can read projects" ON public.projects FOR SELECT USING ( team_id IN ( SELECT team_id FROM public.team_members WHERE user_id = auth.uid() ) ); -- Policy: Team admins can create projects CREATE POLICY "Team admins can create projects" ON public.projects FOR INSERT WITH CHECK ( team_id IN ( SELECT team_id FROM public.team_members WHERE user_id = auth.uid() AND role = 'admin' ) ); -- ---- Service Role Key: Bypasses RLS ---- -- Use ONLY in server-side code that needs full access -- NEVER expose the service role key to the client -- Example: background jobs, webhooks, admin operations -- In your server code: -- import { createClient } from '@supabase/supabase-js' -- const supabase = createClient( -- process.env.NEXT_PUBLIC_SUPABASE_URL!, -- process.env.SUPABASE_SERVICE_ROLE_KEY! -- bypasses RLS -- )
| Context | Factory Function | Cookie Handling | Token Refresh | Use For |
|---|---|---|---|---|
| Client Component | createBrowserClient | document.cookie | Via middleware | UI interactions, real-time subscriptions |
| Server Component | createServerClient + cookies() | next/headers cookies() | Via middleware | SSR auth checks, data fetching |
| Server Action | createServerClient + cookies() | next/headers cookies() | Via middleware | Sign up, sign in, sign out flows |
| Route Handler | createServerClient + cookies() | next/headers cookies() | Via middleware | API routes, webhooks |
| Middleware | createServerClient | request.cookies | Triggers refresh | Token refresh, route protection |
π― Key Takeaways
- @supabase/ssr is mandatory for App Router β provides cookie-based sessions across client and server
- Middleware is non-negotiable β it refreshes access tokens on every request
- Always use getUser() on the server β getSession() reads unvalidated cookies
- Three client factories: browser, server, middleware β each configured for its rendering context
- RLS enforces authorization at the database level β never disable it on user data tables
- OAuth requires a callback route that exchanges the auth code for a session
β Common Mistakes to Avoid
Interview Questions on This Topic
- QWhy is @supabase/ssr required for Next.js App Router, and what happens if you use supabase-js alone?Mid-levelReveal
- QWhat is the difference between getUser() and getSession() in Supabase Auth, and when should you use each?Mid-levelReveal
- QHow does middleware handle token refresh in a Supabase + Next.js application?SeniorReveal
- QWhat is Row Level Security in Supabase and why is it important for authentication?Mid-levelReveal
Frequently Asked Questions
Can I use Supabase Auth with the Pages Router instead of App Router?
Yes, but the client setup differs. The Pages Router uses @supabase/auth-helpers-nextjs (deprecated) or @supabase/ssr with a different cookie configuration. The App Router approach described in this guide uses the latest @supabase/ssr package which is the recommended path for new projects.
How do I customize the email templates for sign-up confirmation and password reset?
Go to Supabase Dashboard > Authentication > Email Templates. You can customize the confirmation, invitation, magic link, and password reset templates. Use {{ .ConfirmationURL }} for the redirect link and {{ .Token }} for the OTP code. Templates support HTML and plain text.
How do I handle session expiry gracefully in the UI?
The middleware handles token refresh automatically β users should not experience session expiry during active use. For inactive sessions (user leaves tab open for days), the refresh token eventually expires. Handle this by checking getUser() on the server and redirecting to login if it returns null. In Client Components, listen for the SIGNED_OUT auth event via supabase.auth.onAuthStateChange().
Can I use Supabase Auth with multiple Supabase projects in a single Next.js app?
Yes, but each project needs its own client instance with its own cookie namespace. Use different cookie name prefixes to avoid conflicts. Create separate factory functions for each project (e.g., createClientProjectA, createClientProjectB) with distinct cookie configurations.
How do I test Supabase Auth in my CI/CD pipeline?
Use the Supabase CLI to run a local Supabase instance in CI. The command supabase start spins up all Supabase services locally. Set the environment variables to point to the local instance. For integration tests, create test users via the admin API (service role key) and test auth flows against the local instance.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.