The Problem
Wiring Supabase into a Next.js App Router project looks simple until server components return stale sessions, middleware breaks auth refresh, or row-level security gets skipped entirely. This guide covers the full integration with the gotchas that actually bite.
Create the Supabase Project
Sign up at supabase.com and create a project in a region near your users. The free tier gives 500 MB database, 1 GB storage, 2 GB egress, and 50,000 monthly active auth users.
From the API settings grab three values:
-
SUPABASE_URL: the project's API endpoint -
SUPABASE_ANON_KEY: public client-side key (safe to ship) -
SUPABASE_SERVICE_ROLE_KEY: server-side admin key (never expose to the browser)
Install SDK and Configure Environment
npm install @supabase/supabase-js @supabase/ssr Add to .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...your-anon-key SUPABASE_SERVICE_ROLE_KEY=eyJ...your-service-role-key The NEXT_PUBLIC_* prefix exposes variables to the browser. The service role key has no prefix because it must stay server-side.
Create Three Supabase Clients
Next.js 16 with the App Router needs a server client, a browser client, and a middleware client.
lib/supabase/server.ts:
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: () => cookieStore.getAll(), setAll: (cookiesToSet) => { cookiesToSet.forEach(({ name, value, options }) => { cookieStore.set(name, value, options); }); }, }, } ); } lib/supabase/client.ts:
import { createBrowserClient } from '@supabase/ssr'; export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); } middleware.ts at project root:
import { createServerClient } from '@supabase/ssr'; import { NextResponse, type NextRequest } from 'next/server'; export async function middleware(request: NextRequest) { let 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 }) => { response.cookies.set(name, value, options); }); }, }, } ); await supabase.auth.getUser(); return response; } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; The middleware refreshes the auth session on every request. Without it, server components see stale sessions.
Wire Auth Into the Template
Replace template auth handlers with Supabase calls.
Login handler:
'use server'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; export async function login(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) return { error: error.message }; redirect('/dashboard'); } Signup handler:
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, options: { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` }, }); if (error) return { error: error.message }; redirect('/auth/check-email'); } OAuth (Google example):
export async function signInWithGoogle() { const supabase = await createClient(); const { data } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` }, }); if (data.url) redirect(data.url); } Callback route at app/auth/callback/route.ts:
import { createClient } from '@/lib/supabase/server'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get('code'); if (code) { const supabase = await createClient(); await supabase.auth.exchangeCodeForSession(code); } return NextResponse.redirect(`${origin}/dashboard`); } Enable OAuth providers in the Supabase dashboard under Authentication > Providers.
Query Database from Server Components
Server components run on the server, so database calls happen inside the Vercel function: no API route needed for read-only views.
import { createClient } from '@/lib/supabase/server'; export default async function ProjectsPage() { const supabase = await createClient(); const { data: projects, error } = await supabase .from('projects') .select('*, tasks(count)') .order('created_at', { ascending: false }); if (error) throw new Error(error.message); return ( <div> {projects.map((p) => ( <div key={p.id}>{p.name}</div> ))} </div> ); } Enable Row-Level Security
This is the step most teams skip. Without RLS, your anon key gives the browser unrestricted access to every table.
For a projects table where each project belongs to a user:
-- Enable RLS on the table alter table projects enable row level security; -- Allow users to select only their own projects create policy "Users can view their own projects" on projects for select using ( auth.uid() = user_id ); -- Allow users to insert projects with their own user_id create policy "Users can insert their own projects" on projects for insert with check ( auth.uid() = user_id ); -- Allow users to update their own projects create policy "Users can update their own projects" on projects for update using ( auth.uid() = user_id ); -- Allow users to delete their own projects create policy "Users can delete their own projects" on projects for delete using ( auth.uid() = user_id ); Run this in the Supabase SQL editor for every table with user-specific data. Test by hitting the API with the anon key from a logged-out browser tab: every query should return empty.
File Uploads via Supabase Storage
Create a storage bucket in the dashboard. Set to "private" for user-uploaded files (avatars, attachments).
Upload from a client component:
'use client'; import { createClient } from '@/lib/supabase/client'; async function uploadAvatar(file: File, userId: string) { const supabase = createClient(); const fileName = `${userId}/${Date.now()}-${file.name}`; const { data, error } = await supabase.storage .from('avatars') .upload(fileName, file, { upsert: true }); if (error) throw error; return data.path; } Get a signed URL for displaying a private file:
const { data } = await supabase.storage .from('avatars') .createSignedUrl(filePath, 3600); // 1 hour expiry Set storage policies the same way as table policies: every bucket should have RLS scoping access to the owning user.
Realtime Subscriptions (Optional)
For live features: kanban boards, chat, presence: Supabase realtime is the fastest path.
'use client'; import { useEffect, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; export function LiveTasks({ projectId }: { projectId: string }) { const [tasks, setTasks] = useState([]); const supabase = createClient(); useEffect(() => { const channel = supabase .channel(`tasks:${projectId}`) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'tasks', filter: `project_id=eq.${projectId}` }, (payload) => { // Handle insert, update, delete console.log(payload); } ) .subscribe(); return () => { supabase.removeChannel(channel); }; }, [projectId]); return <div>...</div>; } Realtime requires enabling replication on the table in the Supabase dashboard. Without it, the subscription succeeds but no events fire.
Common Gotchas
Forgetting RLS. The biggest security risk. Default-deny by enabling RLS, then add specific allow policies. Never ship with RLS off.
Server vs browser client confusion. The server client uses cookies for session; the browser client uses in-memory session. Mixing them causes "user is null" bugs that look like broken auth.
Skipping the middleware. Without auth-refreshing middleware, server components see stale or expired sessions. Symptoms: random logouts, "user is null" after navigation.
Service role key in client code. The service role key bypasses RLS. It must only run on the server. Putting it in a NEXT_PUBLIC_ variable or referencing it from a client component is a critical security incident.
Forgetting emailRedirectTo for signups. Without it, Supabase redirects to its own confirmation page, which doesn't carry the session into your app. Always specify the redirect.
Real-time without replication enabled. Subscribing to changes on a table without replication means silent failure. Enable replication in the dashboard for every table you subscribe to.
Free tier limits. 500 MB database, 1 GB storage, 2 GB egress. Easy to hit if you store user-uploaded files without CDN caching. Plan for Pro tier ($25/month) once you have meaningful users.
This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.

