The most common mistake with Supabase auth in Next.js App Router is doing auth on the client only. Client-side session checks are easily bypassed. Here's how to do it correctly — server-side, with middleware protecting routes before they even render.
Prerequisites
- Next.js 14+ with App Router
- Supabase project with auth enabled
@supabase/ssrpackage (not the oldauth-helpers-nextjs)
npm install @supabase/ssr @supabase/supabase-jsSupabase Client Setup
Three different client configurations for three different contexts:
// lib/supabase/server.ts — for Server Components and Route Handlers
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) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}// lib/supabase/middleware.ts — for middleware (cookie mutation in requests)
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } 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)
);
},
},
}
);
// Refresh session — required before calling getUser()
const { data: { user } } = await supabase.auth.getUser();
return { supabaseResponse, user };
}// lib/supabase/client.ts — for Client Components
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Middleware: Protect Routes at the Edge
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request);
const isAuthPage = request.nextUrl.pathname.startsWith('/auth');
const isProtected = request.nextUrl.pathname.startsWith('/dashboard') ||
request.nextUrl.pathname.startsWith('/account');
// Redirect unauthenticated users away from protected pages
if (isProtected && !user) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = '/auth/login';
redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// Redirect authenticated users away from auth pages
if (isAuthPage && user) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return supabaseResponse;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Login Page with Email + OAuth
// app/auth/login/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ redirectTo?: string }>;
}) {
const { redirectTo } = await searchParams;
async function loginWithEmail(formData: FormData) {
'use server';
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(`/auth/login?error=${encodeURIComponent(error.message)}`);
redirect(redirectTo ?? '/dashboard');
}
async function loginWithGitHub() {
'use server';
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (data.url) redirect(data.url);
}
return (
<div className="mx-auto max-w-sm space-y-6">
<form action={loginWithEmail} className="space-y-4">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Sign in with Email</button>
</form>
<form action={loginWithGitHub}>
<button type="submit">Sign in with GitHub</button>
</form>
</div>
);
}OAuth Callback Route
// app/auth/callback/route.ts
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}/auth/error`);
}Reading the User in Server Components
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/auth/login'); // Fallback if middleware missed it
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
return (
<div>
<h1>Welcome, {profile?.full_name ?? user.email}</h1>
</div>
);
}Common Pitfalls
- Using
getSession()instead ofgetUser():getSession()returns the JWT from the cookie without server-side validation — an attacker can forge it. Always usegetUser()for security checks. - Not calling
updateSession()in middleware: without this, cookies expire and users get silently logged out - Auth on client only: checking
supabase.auth.onAuthStateChangeon the client is not a security control — it's UX - NEXT_PUBLIC vs. server-only keys: never put your service role key in a
NEXT_PUBLIC_variable