Skip to content
June 3, 2025·15 min read

Next.js + Supabase Auth: Server-Side Sessions with App Router

Implement secure authentication in Next.js App Router with Supabase — server-side session validation, protected routes, middleware, and OAuth providers.

Back

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/ssr package (not the old auth-helpers-nextjs)
npm install @supabase/ssr @supabase/supabase-js

Supabase 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 of getUser(): getSession() returns the JWT from the cookie without server-side validation — an attacker can forge it. Always use getUser() 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.onAuthStateChange on 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

Resources

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer · Consultant

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn