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.