October 21, 2025
O. Wolfson
Setting up authentication in modern web applications can be complex, but with Supabase and Next.js 15's App Router, it becomes surprisingly straightforward. This guide will walk you through a complete authentication system with login, signup, logout, and protected routes.
Go to supabase.com → New Project
Choose your organization
Enter:
your-app-nameClick Create new project
Authentication → Settings
Set Site URL:
http://localhost:3000https://yourdomain.comRedirect URLs:
http://localhost:3000/auth/confirm
http://localhost:3000/auth/error
http://localhost:3000/dashboard
http://localhost:3000/
Enable Email provider
Disable “Enable email confirmations” (for dev)
Settings → API
Copy:
bashnpx create-next-app@latest my-auth-app
cd my-auth-app
bashnpm install @supabase/supabase-js @supabase/ssr
Create .env.local:
envNEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
lib/supabase/client.ts)typescript"use client";
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
return createBrowserClient(url, anonKey);
}
lib/supabase/server.ts)typescriptimport { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createServerClientWithCookies() {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore
.getAll()
.map((c) => ({ name: c.name, value: c.value }));
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options as CookieOptions);
});
},
},
}
);
return supabase;
}
lib/supabase/admin.ts)typescriptimport { createClient } from "@supabase/supabase-js";
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
}
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: { persistSession: false },
global: { headers: { "X-Client-Info": "my-app" } },
}
);
Includes AuthButton, LogoutButton, and LoginPage with client/server separation for optimal SSR.
(Full code unchanged.)
typescript"use server";
import { createServerClientWithCookies } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function logout() {
const supabase = await createServerClientWithCookies();
await supabase.auth.signOut();
redirect("/");
}
The middleware is crucial for maintaining user sessions across page navigations. It automatically refreshes authentication tokens and ensures both the server and client have synchronized cookies.
Create middleware.ts in the root of your project:
typescriptimport { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll().map((cookie) => ({
name: cookie.name,
value: cookie.value,
}));
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
}
);
// Refresh session if expired
const {
data: { user },
} = await supabase..();
(request...() && !user) {
url = request..();
url. = ;
.(url);
}
response;
}
config = {
: [
,
],
};
Key Points:
Protected routes ensure only authenticated users can access certain pages. Here's how to implement them using server-side rendering with Next.js 15.
Create app/dashboard/page.tsx:
typescriptimport { createServerClientWithCookies } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = await createServerClientWithCookies();
// Check if user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
// Redirect to login if not authenticated
if (!user) {
redirect("/auth/login");
}
// Render protected content
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-2">Welcome, {user.email}!</h2>
<p className="text-gray-600">
This is a protected route. Only authenticated users can see this page.
</p>
<div className="mt-4 p-4 bg-gray-100 rounded">
User ID: {user.id}
);
}
For protecting multiple routes at once, create a layout wrapper at app/protected/layout.tsx:
typescriptimport { createServerClientWithCookies } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createServerClientWithCookies();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/auth/login");
}
return <>{children}</>;
}
Then create protected routes under app/protected/:
typescript// app/protected/settings/page.tsx
export default async function SettingsPage() {
const supabase = await createServerClientWithCookies();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div>
<h1>Settings</h1>
<p>Protected content for {user?.email}</p>
</div>
);
}
Benefits:
Follow the step-by-step flow to test login, signup, logout, and cookie persistence.
✅ Server-Side Rendering (SSR) ✅ Automatic Session Management ✅ Type Safety ✅ Modern Next.js Patterns
This setup provides a complete, production-ready authentication system using Supabase and Next.js 15. With SSR, secure cookies, and simple APIs, you can build robust user authentication without added complexity.