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.
Top comments (0)