authentication
nextjs
supabase
Implementing Supabase Auth in Next.js: The Complete SSR Guide
The New Approach to Supabase Auth
With the release of the @supabase/ssr
package, implementing authentication in Next.js has become significantly more streamlined. This package replaces the previous auth-helpers
libraries and works across multiple frameworks, providing a consistent approach regardless of which framework you're using.
In this guide, we'll implement Supabase Auth in a Next.js application using the new SSR package.
Understanding the Supabase SSR Package
The @supabase/ssr
package was developed to address the evolving landscape of modern frameworks. As frameworks like Next.js increasingly lean toward server-side rendering, Supabase needed a solution that worked seamlessly in both server and client environments.
Key benefits of the SSR package include:
- Framework-agnostic design
- Simplified cookie management
- Consistent auth state across server and client
- Improved type safety with TypeScript
Setup: Installing the Required Packages
Let's start by installing the necessary packages:
npm install @supabase/supabase-js @supabase/ssr
Creating the Supabase Client
Next, let's create a shared module for our Supabase client. Create a file called lib/supabase.ts
:
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
export const createServerSupabaseClient = () => {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options });
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: "", ...options });
},
},
}
);
};
export const createClientSupabaseClient = () => {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
persistSession: true,
},
}
);
};
Server-Side Authentication with Route Handlers
Let's implement the core authentication endpoints using Next.js route handlers:
Sign In Endpoint
Create a file at app/api/auth/sign-in/route.ts
:
import { createServerSupabaseClient } from "@/lib/supabase";
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const requestUrl = new URL(request.url);
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = requestUrl.searchParams.get("redirect_to") || "/";
const supabase = createServerSupabaseClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.redirect(new URL(redirectTo, requestUrl.origin), {
status: 301,
});
}
Sign Up Endpoint
Create a file at app/api/auth/sign-up/route.ts
:
import { createServerSupabaseClient } from "@/lib/supabase";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const requestUrl = new URL(request.url);
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const redirectTo = requestUrl.searchParams.get("redirect_to") || "/";
const supabase = createServerSupabaseClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${requestUrl.origin}/api/auth/callback`,
},
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.redirect(new URL(redirectTo, requestUrl.origin), {
status: 301,
});
}
Callback Endpoint
Create a file at app/api/auth/callback/route.ts
:
import { createServerSupabaseClient } from "@/lib/supabase";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
if (code) {
const supabase = createServerSupabaseClient();
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(new URL("/", requestUrl.origin));
}
Sign Out Endpoint
Create a file at app/api/auth/sign-out/route.ts
:
import { createServerSupabaseClient } from "@/lib/supabase";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const requestUrl = new URL(request.url);
const supabase = createServerSupabaseClient();
await supabase.auth.signOut();
return NextResponse.redirect(new URL("/login", requestUrl.origin), {
status: 301,
});
}
Client-Side Authentication Components
Now let's create the UI components that interact with these endpoints:
Login Form Component
Create a file at components/auth/LoginForm.tsx
:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("email", email);
formData.append("password", password);
const response = await fetch("/api/auth/sign-in", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to sign in");
}
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-100 text-red-700 rounded">{error}</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
);
}
Protecting Routes with Server Components
One of the biggest advantages of the SSR package is the ability to protect routes using server components. Let's create a middleware function to check authentication:
Create a file at middleware.ts
:
import { createServerClient, type CookieOptions } 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: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
response.cookies.set({ name, value, ...options });
},
remove(name: string, options: CookieOptions) {
response.cookies.set({ name, value: "", ...options });
},
},
}
);
const {
data: { session },
} = await supabase.auth.getSession();
// If the user is not signed in and the route is protected, redirect to login
const isProtectedRoute = request.nextUrl.pathname.startsWith("/dashboard");
if (!session && isProtectedRoute) {
return NextResponse.redirect(new URL("/login", request.url));
}
// If the user is signed in and trying to access login/signup, redirect to dashboard
const isAuthRoute =
request.nextUrl.pathname.startsWith("/login") ||
request.nextUrl.pathname.startsWith("/signup");
if (session && isAuthRoute) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return response;
}
// Specify which routes the middleware should run on
export const config = {
matcher: ["/dashboard/:path*", "/login", "/signup"],
};
Creating a Session Provider for Client Components
To access the authentication state in client components across your app, create a provider:
Create a file at components/auth/SessionProvider.tsx
:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { createClientSupabaseClient } from "@/lib/supabase";
import type { Session, User } from "@supabase/supabase-js";
type AuthContextType = {
session: Session | null;
user: User | null;
isLoading: boolean;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
isLoading: true,
signOut: async () => {},
});
export const useAuth = () => useContext(AuthContext);
export default function SessionProvider({
children,
initialSession,
}: {
children: React.ReactNode;
initialSession: Session | null;
}) {
const [session, setSession] = useState<Session | null>(initialSession);
const [user, setUser] = useState<User | null>(initialSession?.user || null);
const [isLoading, setIsLoading] = useState(false);
const supabase = createClientSupabaseClient();
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user || null);
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const signOut = async () => {
setIsLoading(true);
try {
await fetch("/api/auth/sign-out", {
method: "POST",
});
} finally {
setIsLoading(false);
}
};
return (
<AuthContext.Provider value={{ session, user, isLoading, signOut }}>
{children}
</AuthContext.Provider>
);
}
Initializing the Provider in Your Layout
Finally, update your root layout to include the Session Provider:
Modify your app/layout.tsx
:
import { createServerSupabaseClient } from "@/lib/supabase";
import SessionProvider from "@/components/auth/SessionProvider";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = createServerSupabaseClient();
const {
data: { session },
} = await supabase.auth.getSession();
return (
<html lang="en">
<body>
<SessionProvider initialSession={session}>{children}</SessionProvider>
</body>
</html>
);
}
Creating a Protected Dashboard Page
Let's test our authentication by creating a protected dashboard page:
Create a file at app/dashboard/page.tsx
:
import { createServerSupabaseClient } from "@/lib/supabase";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = createServerSupabaseClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
redirect("/login");
}
// Fetch user-specific data
const { data: todos } = await supabase
.from("todos")
.select("*")
.eq("user_id", session.user.id)
.order("created_at", { ascending: false });
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">
Welcome, {session.user.email}
</h2>
<div className="mt-6">
<h3 className="text-lg font-medium mb-3">Your Todos</h3>
{todos && todos.length > 0 ? (
<ul className="space-y-2">
{todos.map((todo) => (
<li key={todo.id} className="p-3 bg-gray-50 rounded">
{todo.title}
</li>
))}
</ul>
) : (
<p className="text-gray-500">No todos yet.</p>
)}
</div>
<form action="/api/auth/sign-out" method="post" className="mt-8">
<button
type="submit"
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
Sign Out
</button>
</form>
</div>
</div>
);
}
Using the Auth State in Client Components
Here's how to use the auth state in any client component:
"use client";
import { useAuth } from "@/components/auth/SessionProvider";
export default function UserProfile() {
const { user, signOut, isLoading } = useAuth();
if (!user) {
return <div>Not logged in</div>;
}
return (
<div>
<p>Logged in as: {user.email}</p>
<button
onClick={signOut}
disabled={isLoading}
className="bg-red-500 text-white px-4 py-2 rounded"
>
{isLoading ? "Signing out..." : "Sign Out"}
</button>
</div>
);
}
Best Practices for Supabase Auth in Next.js
Based on our implementation experience, here are some best practices:
- Keep cookie management consistent - Let Supabase handle cookies via the SSR package
- Use server components for data fetching - They provide better performance and security
- Implement RLS policies - Always secure your database with Row Level Security
- Refresh tokens automatically - The SSR package handles this for you
- Implement proper error handling - Always display meaningful error messages to users
Conclusion
The new @supabase/ssr
package significantly simplifies authentication implementation in Next.js. By properly integrating server and client components, you can create a seamless authentication experience while maintaining the performance benefits of server-side rendering.
This approach works particularly well with newer Next.js features like the App Router, Server Components, and Route Handlers, giving you a modern, type-safe authentication system for your Next.js application.

Fekri