Supabase & PostgreSQL
Supabase Authentication: The Complete Guide for Next.js Apps
TL;DR
Supabase Authentication gives you a production-ready auth system in minutes, but the gap between "working in localhost" and "safe in production" is where most developers get burned. I have shipped Supabase Auth on uvin.lk↗, EuroParts Lanka, and FreshMart — three very different apps with very different auth requirements. This guide covers everything I wish someone had told me on day one: setting up the Supabase client for Next.js 16 Server Components, wiring email/password and OAuth flows, writing middleware that actually protects your routes, crafting RLS policies that do not silently fail, and the exact production checklist I run before every launch. No docs rehash. Real code from real apps.
Why Supabase Auth
I have tried most auth solutions for Next.js — NextAuth, Clerk, Auth0, Firebase Auth, rolling my own with JWTs. Supabase authentication is the one I keep coming back to, and here is why.
First, it is not just auth. It is auth that lives next to your database. When you sign a user up with Supabase, that user exists in the auth.users table, and you can reference auth.uid() directly in your Row Level Security policies. No syncing user IDs between services. No webhooks to keep two systems in agreement. One source of truth.
Second, the pricing makes sense for indie projects and client work. You get 50,000 monthly active users on the free tier. For the projects I build through my services, that covers the entire beta phase and usually the first year of production.
Third, it handles the things I do not want to think about: email verification, password reset flows, OAuth token refresh, session management. These are solved problems that I have no interest in re-solving.
What Supabase Auth does not do well: custom MFA flows (it supports TOTP but configuration is limited), enterprise SSO out of the box (you need the Pro plan for SAML), and fine-grained session management (you cannot easily invalidate individual sessions from the dashboard). Know the trade-offs before you commit.
Setting Up Supabase Auth with Next.js 16
The biggest mistake I see developers make is using the wrong Supabase client in the wrong context. Next.js 16 has Server Components, Client Components, Route Handlers, Server Actions, and Middleware — each needs a different client configuration. Get this wrong and you will spend hours debugging auth state that works on one page but breaks on another.
Install the packages
npm install @supabase/supabase-js @supabase/ssrThe @supabase/ssr package is critical. It handles cookie-based session management for server-side rendering. Do not try to use @supabase/supabase-js alone in a Next.js app — you will lose sessions on every page navigation.
Environment variables
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyThese are safe to expose publicly. The anon key is designed to be used in the browser — it only has the permissions you grant through RLS. Never put your service_role key in NEXT_PUBLIC_ variables.
Browser client (Client Components)
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Server client (Server Components, Route Handlers, Server Actions)
// lib/supabase/server.ts
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) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
);
}That try/catch in setAll is not sloppy error handling — it is intentional. Server Components cannot set cookies. When this function is called from a Server Component, the cookie write will fail, and that is fine as long as your middleware handles session refresh (which we will set up next). This caught me off guard for an entire afternoon on the EuroParts Lanka build.
Middleware client
// lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { type 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)
);
},
},
}
);
// IMPORTANT: Do not remove this line.
// It refreshes the auth token and keeps the session alive.
await supabase.auth.getUser();
return supabaseResponse;
}The getUser() call is not optional. It triggers the token refresh cycle. Without it, your users will get logged out after the JWT expires (default: 1 hour). I learned this the hard way when FreshMart users started reporting random logouts during checkout.
Email/Password Authentication
Email and password is still the default for most apps I build. Here is the pattern I use across projects.
Sign up
// app/(auth)/sign-up/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { z } from "zod";
const signUpSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
fullName: z.string().min(2, "Name must be at least 2 characters"),
});
export async function signUp(formData: FormData) {
const parsed = signUpSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
fullName: formData.get("fullName"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
const supabase = await createClient();
const { error } = await supabase.auth.signUp({
email: parsed.data.email,
password: parsed.data.password,
options: {
data: {
full_name: parsed.data.fullName,
},
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) {
return { error: { server: [error.message] } };
}
redirect("/check-email");
}Three things to notice. First, Zod validation happens before the Supabase call. Never trust form input. Second, options.data stores metadata on the auth.users row — you can access it later with user.user_metadata.full_name. Third, emailRedirectTo must be an absolute URL and it must be listed in your Supabase dashboard under Authentication > URL Configuration > Redirect URLs. If it is not listed, Supabase silently ignores it and redirects to your site URL instead. This has caused me more debugging sessions than I care to admit.
Sign in
// app/(auth)/sign-in/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function signIn(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) {
return { error: error.message };
}
redirect("/dashboard");
}Auth callback handler
After email verification or OAuth, Supabase redirects to your callback URL with a code in the query string. You need a route to exchange that code for a session.
// app/auth/callback/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/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}`);
}
}
// Something went wrong — redirect to an error page
return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}This is the PKCE flow. Supabase uses it by default for server-side apps. The code is single-use and short-lived. Do not try to use it twice or store it.
OAuth Providers — Google and GitHub
OAuth is where Supabase Auth really shines. Adding Google or GitHub sign-in takes about ten minutes of actual work, and most of that is configuring the provider dashboard.
Server Action for OAuth
// app/(auth)/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function signInWithProvider(provider: "google" | "github") {
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
queryParams: provider === "google"
? { access_type: "offline", prompt: "consent" }
: undefined,
},
});
if (error) {
return { error: error.message };
}
redirect(data.url);
}The queryParams for Google are important. Without prompt: "consent", returning users skip the consent screen, which means you do not get a refresh token. Without access_type: "offline", you only get an access token that expires in an hour. If you need to call Google APIs on behalf of the user (like reading their calendar), you need both.
Client Component
// components/auth/oauth-buttons.tsx
"use client";
import { signInWithProvider } from "@/app/(auth)/actions";
export function OAuthButtons() {
return (
<div className="flex flex-col gap-3">
<button
onClick={() => signInWithProvider("google")}
className="flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
>
<GoogleIcon className="size-5" />
Continue with Google
</button>
<button
onClick={() => signInWithProvider("github")}
className="flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-white/10"
>
<GitHubIcon className="size-5" />
Continue with GitHub
</button>
</div>
);
}A gotcha I hit on every project: make sure your OAuth redirect URL in the Google Cloud Console and GitHub OAuth App settings matches exactly what you have in Supabase. No trailing slashes. No http vs https mismatches. Google is particularly strict about this — a single character difference and you get a cryptic "redirect_uri_mismatch" error with zero useful information.
Server-Side Auth with Middleware
Middleware is the backbone of auth in Next.js. Every request passes through it, which makes it the right place to refresh sessions and protect routes. Here is the middleware I use on every project.
// middleware.ts
import { updateSession } from "@/lib/supabase/middleware";
import { type NextRequest, NextResponse } from "next/server";
const publicRoutes = ["/", "/sign-in", "/sign-up", "/auth/callback", "/auth/confirm"];
const authRoutes = ["/sign-in", "/sign-up"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Always refresh the session
const response = await updateSession(request);
// Check if the user is authenticated
const supabase = createMiddlewareClient(request, response);
const { data: { user } } = await supabase.auth.getUser();
// Redirect authenticated users away from auth pages
if (user && authRoutes.some((route) => pathname.startsWith(route))) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Redirect unauthenticated users to sign-in
if (!user && !publicRoutes.some((route) => pathname.startsWith(route))) {
const redirectUrl = new URL("/sign-in", request.url);
redirectUrl.searchParams.set("next", pathname);
return NextResponse.redirect(redirectUrl);
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};Two things I want to highlight. First, I use getUser() instead of getSession(). The getSession() method reads the JWT from cookies and decodes it locally — it does not verify the signature with Supabase. In middleware, where you are making authorization decisions, you want getUser() which makes a server call to verify the token is legitimate. The performance cost is a few milliseconds. The security cost of skipping it could be session forgery.
Second, the matcher pattern excludes static assets. Without this, your middleware runs on every image, font, and CSS file request. On the FreshMart product catalog with hundreds of images, this was adding 200ms to page loads before I fixed it.
Protecting Server Components directly
Sometimes middleware is not enough. For pages that load sensitive data, I add a second check in the Server Component itself.
// 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("/sign-in");
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single();
return (
<main>
<h1>Welcome back, {profile?.full_name}</h1>
{/* Dashboard content */}
</main>
);
}Defense in depth. Middleware protects routes. Server Components verify before fetching. RLS protects the data itself. Three layers. If one fails, the others catch it.
Row Level Security with Auth
Row Level Security is the killer feature that makes Supabase Auth more than just a login system. RLS policies use the authenticated user's JWT to control database access at the row level. No application code needed — the database enforces it.
Here is the RLS setup I use for a typical user profiles table.
-- Create the profiles table
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
full_name text,
avatar_url text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Enable RLS
alter table public.profiles enable row level security;
-- Users can read their own profile
create policy "Users can view own profile"
on public.profiles for select
using (auth.uid() = id);
-- Users can update their own profile
create policy "Users can update own profile"
on public.profiles for update
using (auth.uid() = id)
with check (auth.uid() = id);
-- Automatically create a profile on sign up
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
new.raw_user_meta_data ->> 'full_name',
new.raw_user_meta_data ->> 'avatar_url'
);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();The trigger function is security definer, which means it runs with the permissions of the function owner (usually postgres), not the calling user. This is necessary because the user does not have permission to insert into profiles directly — the RLS policy only allows select and update. The trigger bypasses RLS to create the initial row.
Multi-tenant RLS
For EuroParts Lanka, I needed tenant-scoped access. Here is the pattern.
-- Orders belong to a user within an organization
create policy "Users can view orders in their org"
on public.orders for select
using (
org_id in (
select org_id from public.org_members
where user_id = auth.uid()
)
);The subquery approach is cleaner than JOINs in RLS policies. Supabase optimizes these into index scans, so performance is good as long as you have an index on org_members(user_id).
The RLS gotcha that will ruin your day
If you forget to enable RLS on a table, Supabase allows all access by default. If you enable RLS but add no policies, Supabase blocks all access. Both are dangerous in different ways. I add this check to every project's CI pipeline:
-- Find tables without RLS enabled (run this in your test suite)
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and tablename not in (
select tablename::text
from pg_catalog.pg_policies
where schemaname = 'public'
);If this query returns rows, something is unprotected.
Protected API Routes
For Route Handlers that serve API requests, the pattern is similar but you need to handle errors as JSON responses, not redirects.
// app/api/profile/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
const { data: profile, error } = await supabase
.from("profiles")
.select("id, full_name, avatar_url, created_at")
.eq("id", user.id)
.single();
if (error) {
return NextResponse.json(
{ code: "NOT_FOUND", message: "Profile not found" },
{ status: 404 }
);
}
return NextResponse.json({ data: profile });
}
export async function PATCH(request: Request) {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
const body = await request.json();
const { data, error } = await supabase
.from("profiles")
.update({
full_name: body.fullName,
updated_at: new Date().toISOString(),
})
.eq("id", user.id)
.select()
.single();
if (error) {
return NextResponse.json(
{ code: "UPDATE_FAILED", message: error.message },
{ status: 500 }
);
}
return NextResponse.json({ data });
}Even though RLS protects the data, I still check auth.getUser() in the Route Handler. RLS will return empty results for unauthenticated users — it will not throw an error. Without the explicit auth check, an unauthenticated request to PATCH /api/profile would silently do nothing and return a success response. That is confusing at best and a security signal leak at worst.
Common Mistakes I Have Made
After shipping Supabase Auth on multiple production apps, here are the mistakes I keep a checklist for.
1. Using getSession() for authorization
getSession() parses the JWT locally. It does not verify the signature with Supabase. In a Server Component or Route Handler where you are deciding what data to show, always use getUser(). The session can be tampered with in cookies. The user verification cannot.
2. Forgetting to add redirect URLs in the dashboard
Every redirect URL used in OAuth, email confirmation, and password reset must be explicitly listed in Supabase Authentication > URL Configuration. Localhost URLs for development, staging URLs, production URLs — all of them. Supabase does support wildcard patterns (like https://*.vercel.app/**), but be careful with these in production.
3. Not handling the email confirmation state
When a user signs up, they are not immediately confirmed (unless you disable email confirmation in the dashboard, which you should not do in production). The signUp response includes a user object even before confirmation, but the user cannot sign in until they click the email link. Show a clear "check your email" page. Do not leave them staring at a sign-in form wondering why their password does not work.
4. Ignoring token refresh in long-lived pages
SPAs and dashboards where users stay on a single page for hours will eventually hit a JWT expiry. The @supabase/ssr package handles refresh in middleware on navigation, but for pages that never navigate, you need the onAuthStateChange listener.
// hooks/use-auth-listener.ts
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function useAuthListener() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event) => {
if (event === "SIGNED_OUT") {
router.push("/sign-in");
}
if (event === "TOKEN_REFRESHED") {
router.refresh();
}
}
);
return () => subscription.unsubscribe();
}, [router, supabase]);
}5. Not setting up the profiles trigger
If you create a profiles table but forget the trigger that creates a profile row on sign up, your application code will fail with "profile not found" errors for every new user. The trigger I showed in the RLS section is not optional — it is load-bearing infrastructure.
Production Checklist
This is the exact checklist I run before launching any Supabase Auth project. I keep it in my project's CLAUDE.md and check every item before the production deploy.
Dashboard Configuration:
- [ ] Email templates customized (not the Supabase defaults)
- [ ] Site URL set to production domain
- [ ] All redirect URLs listed (production + staging)
- [ ] Email confirmation enabled
- [ ] Password minimum length set to 8+
- [ ] Rate limiting enabled on auth endpoints
Code:
- [ ] Server client uses
@supabase/ssr, not@supabase/supabase-js - [ ] Middleware refreshes session on every request
- [ ]
getUser()used for authorization (notgetSession()) - [ ] Auth callback route handles errors gracefully
- [ ] Sign-out clears session and redirects
- [ ] Zod validation on all auth form inputs
Database:
- [ ] RLS enabled on every public table
- [ ] Every table with user data has an RLS policy
- [ ] Profiles trigger creates row on sign up
- [ ] No tables accessible without authentication (unless intentional)
- [ ]
service_rolekey never used in client code
Security:
- [ ]
NEXT_PUBLIC_only contains anon key and URL - [ ]
service_rolekey in server-only environment variables - [ ] HTTPS enforced in production
- [ ] Secure cookie flags enabled
- [ ] CORS configured to specific origins
Testing:
- [ ] Sign up flow works end to end
- [ ] Email confirmation flow works
- [ ] OAuth flow works for each provider
- [ ] Password reset flow works
- [ ] Unauthenticated users cannot access protected routes
- [ ] RLS policies tested with different user roles
Key Takeaways
- Use the right client in the right context. Browser client for Client Components. Server client for Server Components and Route Handlers. Middleware client for middleware. Getting this wrong is the number one source of auth bugs in Next.js.
- Always use `getUser()` for authorization decisions.
getSession()is fine for checking if a user is logged in on the client side. It is not fine for deciding what data to show on the server side.
- RLS is not optional. If you are using Supabase and not using Row Level Security, you are building a house without locks. Enable it on every table. Write policies. Test them.
- Middleware does two jobs: refresh tokens and protect routes. The token refresh is the one people forget. Without it, users get logged out after an hour.
- Defense in depth. Middleware protects routes. Server Components verify before fetching. RLS protects the data. API routes check auth before responding. One layer is a suggestion. Three layers is security.
Supabase Authentication is genuinely one of the best auth solutions available for Next.js apps today. The official Supabase Auth documentation↗ is solid and improving constantly. But documentation shows you the happy path. Production shows you everything else. I hope this guide helps you skip the painful parts.
*Uvin Vindula is a Web3 and AI engineer based in Sri Lanka and the UK. He builds production-grade applications with Next.js, Supabase, and Solidity, and writes about the patterns that actually work in production. Find more of his work at uvin.lk↗ or reach out at contact@uvin.lk.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.