Next.js Supabase Authentication: Complete Guide - Part 2
Integrate Supabase authentication into your Next.js app. Learn server actions, middleware, route protection, and email authentication setup.
In this series

When Forms Finally Connected to a Real Database
In Part 1, we built beautiful authentication forms. They validated perfectly, looked great, but didn't actually do anything. That frustrated feeling of having a Ferrari without gas? That was me staring at those console.log statements.
Today, we're changing that. We're connecting our Next.js app to Supabase, implementing real authentication, protecting routes, and making everything actually work. By the end of this post, users will be able to sign up, log in, and reset their passwords for real.
🔗 Check out the full code for this series here
Why Supabase Changed My Backend Game
Supabase is like Firebase, but built on PostgreSQL. That means you get the ease of a backend-as-a-service with the power and reliability of Postgres. Authentication, database, storage, and real-time subscriptions—all in one platform.
The best part? The free tier is generous enough for most projects. You can build and deploy without touching your credit card.
Creating Your Supabase Project
Let's get started. Head over to supabase.com and create an account if you haven't already.
Setting Up the Project
- Click "New Project" in your dashboard
- Choose your organization (or create one)
- Give your project a name—I used "supabase-auth-demo"
- Important: Choose a strong database password and save it somewhere secure. You'll need it later, and you only get to see it once

- Select a database region close to your users for better performance
- Click "Create new project" and wait a couple minutes while Supabase provisions everything
Getting Your API Keys
Once your project is ready, you need two things: your Project URL and your anonymous public key.
- Go to Project Settings (bottom of the left sidebar)
- Click "API" in the settings menu
- You'll see your Project URL and anon public key

Create a .env.local file in your project root and add these:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
The NEXT_PUBLIC_ prefix makes these available on the client side. Don't worry—the anonymous key is designed to be public. Row-Level Security (which we'll cover in Part 4) keeps your data safe.
Understanding Server vs Client Supabase Clients
Here's something that confused me initially: Supabase needs different clients for different parts of your Next.js app.
Server Client: Used in Server Components, Server Actions, Route Handlers—anything running on your server
Browser Client: Used in Client Components, hooks, and anything running in the user's browser
Why the distinction? Cookies. The server client needs to access Next.js cookies to maintain user sessions. The browser client uses the browser's native cookie handling.
Installing Dependencies
First, let's install the Supabase packages:
pnpm add @supabase/ssr @supabase/supabase-js
Creating the Browser Client
Create utils/supabase/client.ts:
import { createBrowserClient } from "@supabase/ssr";
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
Simple and clean. This client will be used in all our Client Components.
Creating the Server Client
Create utils/supabase/server.ts:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export const createClient = async () => {
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) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch (error) {
// Server Component tried to set cookie
// Middleware will handle this
console.error(error);
}
},
},
},
);
};
The server client handles cookies explicitly. This is crucial for maintaining user sessions across server-rendered pages.
Defining Protected Routes
Before we build middleware, let's define which routes need authentication. Add these to the end of lib/constants.ts:
export const CLIENT_ROUTES = {
HOME: "/",
// App routes
DASHBOARD: "/dashboard",
RESET_PASSWORD: "/reset-password",
// Auth routes
LOGIN: "/login",
SIGNUP: "/signup",
FORGOT_PASSWORD: "/forgot-password",
};
export const PROTECTED_ROUTES = [
CLIENT_ROUTES.DASHBOARD,
CLIENT_ROUTES.RESET_PASSWORD,
];
export enum EServerResponseCode {
SUCCESS,
FAILURE,
}
This approach is cleaner than scattering route strings throughout your codebase. Change a route once, and it updates everywhere.
Let's also install lodash for some helpful utility functions:
pnpm add lodash-es
pnpm add -D @types/lodash-es
Creating a Protected Dashboard
Let's create a simple protected page at app/dashboard/page.tsx:
"use client";
import { Button } from "@/components/ui/button";
import { signOutAction } from "@/actions/supabase";
import { useRouter } from "next/navigation";
import { CLIENT_ROUTES } from "@/lib/constants";
export default function DashboardPage() {
const router = useRouter();
async function onLogout() {
try {
await signOutAction();
router.push(CLIENT_ROUTES.LOGIN);
} catch (error) {
alert("Some error occurred");
}
}
return (
<div className="mt-32 text-center">
<h1 className="text-3xl font-bold mb-4">Protected Dashboard</h1>
<p className="mb-8">You can only view this page when you're logged in.</p>
<form action={onLogout} className="mt-4">
<Button>Logout</Button>
</form>
</div>
);
}
Right now, anyone can access this page. Let's fix that with middleware.
Building the Supabase Middleware
This is where the magic happens. Middleware runs before every request, letting us check authentication and redirect as needed.
Create utils/supabase/middleware.ts:
import { createServerClient } from "@supabase/ssr";
import { includes, isEmpty } from "lodash-es";
import { type NextRequest, NextResponse } from "next/server";
import { CLIENT_ROUTES, PROTECTED_ROUTES } from "@/lib/constants";
export const updateSession = async (request: NextRequest) => {
const headers = new Headers(request.headers);
headers.set("x-current-path", request.nextUrl.pathname);
try {
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();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
response = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
// Refresh session if expired
const {
data: { user },
} = await supabase.auth.getUser();
// Not logged in, trying to access protected route
if (isEmpty(user) && includes(PROTECTED_ROUTES, request.nextUrl.pathname)) {
const url = request.nextUrl.clone();
url.pathname = CLIENT_ROUTES.LOGIN;
return NextResponse.redirect(url, { headers });
}
// Logged in, trying to access auth pages
if (
!isEmpty(user) &&
(request.nextUrl.pathname === CLIENT_ROUTES.LOGIN ||
request.nextUrl.pathname === CLIENT_ROUTES.SIGNUP ||
request.nextUrl.pathname === CLIENT_ROUTES.FORGOT_PASSWORD)
) {
const url = request.nextUrl.clone();
url.pathname = CLIENT_ROUTES.DASHBOARD;
return NextResponse.redirect(url, { headers });
}
return response;
} catch (error) {
console.error(error);
return NextResponse.next({
request: {
headers,
},
});
}
};
What This Middleware Does
- Refreshes Sessions: Automatically refreshes expired sessions
- Protects Routes: Redirects unauthenticated users away from protected pages
- Smart Redirects: Sends logged-in users to the dashboard if they try to access login pages
Now hook it up in middleware.ts at the project root:
import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
The matcher ensures middleware runs on all routes except static assets.
Setting Up Auth Callbacks
Supabase needs two callback routes to handle authentication flows:
Callback Route
Create app/auth/callback/route.ts:
import { isEmpty } from "lodash-es";
import { NextResponse } from "next/server";
import { CLIENT_ROUTES } from "@/lib/constants";
import { createClient } from "@/utils/supabase/server";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const origin = requestUrl.origin;
const redirectTo = requestUrl.searchParams.get("redirect_to")?.toString();
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!isEmpty(error)) {
return NextResponse.redirect(
`${origin}${CLIENT_ROUTES.LOGIN}?failed=true`,
);
}
}
if (redirectTo) {
return NextResponse.redirect(`${origin}${redirectTo}`);
}
return NextResponse.redirect(`${origin}${CLIENT_ROUTES.DASHBOARD}`);
}
This route exchanges temporary authentication codes for user sessions.
Confirm Route
Create app/auth/confirm/route.ts:
import { type EmailOtpType } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { type NextRequest } from "next/server";
import { CLIENT_ROUTES } from "@/lib/constants";
import { createClient } from "@/utils/supabase/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? CLIENT_ROUTES.DASHBOARD;
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
redirect(next);
}
}
redirect("/error");
}
This verifies one-time passwords for email confirmations and password resets.
Creating Authentication Server Actions
Now for the heart of our authentication system. Create actions/supabase.ts:
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { CLIENT_ROUTES, EServerResponseCode } from "@/lib/constants";
import {
ForgotPasswordFormSchema,
ResetPasswordFormSchema,
SignupFormSchema,
type TForgotPasswordFormSchema,
type TLoginFormSchema,
type TResetPasswordFormSchema,
type TSignupFormSchema,
} from "@/lib/constants";
import { createClient } from "@/utils/supabase/server";
export const signupAction = async (formData: TSignupFormSchema) => {
const { email, password } = formData;
const supabase = await createClient();
const origin = (await headers()).get("origin");
const validation = SignupFormSchema.safeParse(formData);
if (!validation.success) {
return {
code: EServerResponseCode.FAILURE,
error: validation.error,
message: "Signup failed",
};
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
},
});
if (error) {
console.error(error.code + " " + error.message);
return {
code: EServerResponseCode.FAILURE,
error,
message: "Something went wrong! Please try again",
};
} else {
return {
code: EServerResponseCode.SUCCESS,
message:
"Signup successful! Please check your email to confirm your account",
};
}
};
export const loginAction = async (formData: TLoginFormSchema) => {
const { email, password } = formData;
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.error(error.code + " " + error.message);
return {
code: EServerResponseCode.FAILURE,
error,
message: "Invalid credentials",
};
} else {
redirect(CLIENT_ROUTES.DASHBOARD);
}
};
export const forgotPasswordAction = async (
formData: TForgotPasswordFormSchema,
) => {
const { email } = formData;
const supabase = await createClient();
const origin = (await headers()).get("origin");
const validation = ForgotPasswordFormSchema.safeParse(formData);
if (!validation.success) {
return {
code: EServerResponseCode.FAILURE,
error: validation.error,
message: "Password reset failed! Please try again",
};
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=${CLIENT_ROUTES.RESET_PASSWORD}`,
});
if (error) {
console.error(error.code + " " + error.message);
return {
code: EServerResponseCode.FAILURE,
error,
message: "Something went wrong! Please try again",
};
} else {
return {
code: EServerResponseCode.SUCCESS,
message: "Success! Please check your email to reset your password",
};
}
};
export const resetPasswordAction = async (
formData: TResetPasswordFormSchema,
) => {
const supabase = await createClient();
const { password } = formData;
const validation = ResetPasswordFormSchema.safeParse(formData);
if (!validation.success) {
return {
code: EServerResponseCode.FAILURE,
error: validation.error,
message: "Password reset failed! Please try again",
};
}
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
return {
code: EServerResponseCode.FAILURE,
error,
message: "Something went wrong! Please try again",
};
} else {
return {
code: EServerResponseCode.SUCCESS,
message: "Password changed successfully",
};
}
};
export const signOutAction = async () => {
try {
const supabase = await createClient();
await supabase.auth.signOut();
return {
code: EServerResponseCode.SUCCESS,
message: "User logged out successfully!",
};
} catch (error) {
console.error(error);
return {
code: EServerResponseCode.FAILURE,
message: "Failed to logout! Please try again",
};
}
};
Connecting Forms to Server Actions
Now let's make our forms actually work. Update each form's onSubmit function:
Signup Form
In app/signup/page.tsx, update the onSubmit:
import { isEmpty } from "lodash-es";
import { signupAction } from "@/actions/supabase";
import { EServerResponseCode } from "@/lib/constants";
async function onSubmit(values: TSignupFormSchema) {
try {
const response = await signupAction(values);
if (isEmpty(response) || response.code !== EServerResponseCode.SUCCESS) {
alert("Failed to signup!");
} else {
form.reset();
alert(response.message);
}
} catch (error) {
console.error("Signup failed:", error);
alert("Failed to signup!");
}
}
Login Form
In app/login/page.tsx:
import { isEmpty } from "lodash-es";
import { loginAction } from "@/actions/supabase";
import { EServerResponseCode } from "@/lib/constants";
async function onSubmit(values: TLoginFormSchema) {
try {
const response = await loginAction(values);
if (!isEmpty(response) && response.code === EServerResponseCode.FAILURE) {
alert(response.message);
}
} catch (error) {
console.error("Login failed:", error);
}
}
Forgot Password Form
In app/forgot-password/page.tsx:
import { isEmpty } from "lodash-es";
import { forgotPasswordAction } from "@/actions/supabase";
import { EServerResponseCode } from "@/lib/constants";
async function onSubmit(values: TForgotPasswordFormSchema) {
try {
const response = await forgotPasswordAction(values);
if (isEmpty(response) || response.code !== EServerResponseCode.SUCCESS) {
alert("Failed to send verification link. Please try again");
} else {
alert("Verification link sent. Please check your email");
form.reset();
}
} catch (error) {
console.error("Forgot password failed:", error);
alert("Failed to send verification link. Please try again");
}
}
Reset Password Form
In app/reset-password/page.tsx:
import { isEmpty } from "lodash-es";
import { useRouter } from "next/navigation";
import { resetPasswordAction } from "@/actions/supabase";
import { CLIENT_ROUTES, EServerResponseCode } from "@/lib/constants";
const router = useRouter();
async function onSubmit(values: TResetPasswordFormSchema) {
try {
const response = await resetPasswordAction(values);
if (isEmpty(response) || response.code !== EServerResponseCode.SUCCESS) {
alert(response.message);
console.log(response.error);
} else {
form.reset();
alert(response.message);
router.push(CLIENT_ROUTES.DASHBOARD);
}
} catch (error) {
console.error("Password reset error:", error);
alert("Password reset failed! Please try again");
}
}
Testing the Complete Flow
Let's test everything:
- Signup: Visit
/signup, create an account with a real email - Email Verification: Check your inbox for Supabase's confirmation email
- Click Confirm: This should redirect you to the dashboard
- Logout: Click logout on the dashboard
- Login: Try logging in with your credentials
- Password Reset: Test the forgot password flow
Debugging Tips
If something doesn't work:
- Check browser console for client-side errors
- Check terminal for server-side errors
- Important: Check Supabase logs in your project dashboard (Logs section in sidebar)
- Verify your environment variables are correct
- Make sure you confirmed your email
Production Considerations
Before deploying to production:
Custom SMTP
Supabase's default email service is rate-limited and not reliable for production. Configure your own SMTP provider (SendGrid, Mailgun, etc.) in Supabase project settings.
Email Templates
Customize the email templates in Supabase to match your brand. Go to Authentication > Email Templates in your Supabase dashboard.
Token Expiration
For security, set appropriate token expiration times for password reset links. Do this in Authentication > Settings.
What We Accomplished
We built a complete, working authentication system:
- ✅ Server and client Supabase clients
- ✅ Route protection middleware
- ✅ Email authentication (signup, login, logout)
- ✅ Password reset flow
- ✅ Proper error handling
In Part 3, we'll add Google OAuth authentication, making it even easier for users to sign up and log in. The social login experience is so smooth, you'll wonder why you ever did email-only authentication.
🔗 Check out the full code for this series here
Authentication is the foundation of secure applications. If you found this guide helpful, share it with your developer friends. Connect with me on Twitter or LinkedIn for more full-stack development tips.
Support My Work
If this guide helped you with this topic, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for developers.
☕ Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!
Cover image by Erik Mclean on Unsplash