Next.js & React
Next.js Authentication Patterns in 2026: What Actually Works
TL;DR
I have shipped production apps with Supabase Auth, NextAuth (now Auth.js), Clerk, and custom JWT implementations. My default recommendation in 2026 is Supabase Auth paired with Row Level Security. It gives you authentication, authorization, and data-level security in a single stack with zero additional services. NextAuth/Auth.js is still the right call when you need maximum provider flexibility or already have a custom database. Clerk is the premium option when you need pre-built UI components and your budget allows $25+/month. Custom JWT is a last resort, not a first choice. In this article, I break down every approach with real code, show you exactly how I handle sessions, protect API routes, and wire up RLS policies, and give you the decision framework I use on every new project. If you are starting a Next.js app today, start here.
The Auth Landscape in 2026
Authentication in Next.js has matured significantly since the chaos of 2023-2024. Back then, the App Router was new, Server Components broke half the auth libraries, and everyone was confused about where to validate sessions. The landscape has settled, and there are now clear winners depending on your use case.
Here is what has changed:
Server Components are the default. Authentication checks happen on the server before any HTML reaches the client. This is a fundamental shift from the SPA era where auth was a client-side redirect dance. In 2026, if your auth library cannot run in a Server Component, it is already dead.
Middleware is the first line of defense. Next.js middleware runs at the edge before your route handlers or page components execute. Every serious auth setup now uses middleware for route protection, token refresh, and redirect logic.
Cookies beat tokens for web apps. The industry has largely moved away from localStorage JWTs for web applications. HttpOnly cookies with proper SameSite attributes are the standard. Tokens still matter for mobile apps and third-party API access, but for a Next.js web app, cookies win on security.
Row Level Security is mainstream. The combination of auth + database-level security policies has gone from a Postgres niche feature to a production standard, primarily driven by Supabase making it accessible. This eliminates an entire class of authorization bugs.
The approaches I will cover, in order of my preference:
- Supabase Auth (my default)
- NextAuth / Auth.js (the flexible option)
- Clerk (the premium option)
- Custom JWT (when nothing else fits)
Supabase Auth — My Default
Supabase Auth is my go-to for every new project unless there is a specific reason to use something else. The reason is simple: authentication and authorization are not separate problems. Supabase treats them as one system. Your auth tokens flow directly into your database policies through Row Level Security, which means you cannot accidentally forget an authorization check. The database enforces it.
Here is how I set it up in a Next.js project:
// 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) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}// lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
const 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, options }) => {
request.cookies.set(name, value);
supabaseResponse.cookies.set(name, value, options);
});
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}The middleware handles two things: refreshing the session cookie on every request and redirecting unauthenticated users away from protected routes. The setAll callback is critical because Supabase might refresh the token during getUser(), and you need those new cookies to propagate to the response.
In any Server Component, getting the current user is one line:
// 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("/login");
}
const { data: projects } = await supabase
.from("projects")
.select("*")
.order("created_at", { ascending: false });
// RLS ensures this only returns the user's own projects
return <ProjectList projects={projects} />;
}Notice I do not filter by user_id in the query. The RLS policy handles that. I will cover exactly how that works in the Auth + RLS section later.
Why Supabase Auth is my default:
- Zero additional services. Auth, database, storage, and realtime in one platform.
- RLS integration means authorization bugs are structurally impossible (when policies are correct).
- The
@supabase/ssrpackage handles cookie management properly, including token refresh. - Social providers (Google, GitHub, Apple, etc.) are configured in the dashboard, not in code.
- Email/password, magic link, phone OTP, and OAuth all work out of the box.
- The free tier covers most side projects and early-stage startups.
Where Supabase Auth falls short:
- If you are not using Supabase as your database, adding it just for auth creates unnecessary complexity.
- Enterprise SSO (SAML) requires the Pro plan.
- The pre-built UI components (
@supabase/auth-ui-react) are functional but not beautiful. I always build custom auth forms.
NextAuth / Auth.js — When It Makes Sense
Auth.js (the framework-agnostic rebrand of NextAuth) is the right choice when you need maximum control over your auth flow, when you are using a database that is not Supabase, or when you need to support a provider that Supabase does not offer.
I have used Auth.js on projects with custom PostgreSQL databases managed by Prisma, and it works well once you get past the initial configuration. The v5 API is significantly cleaner than v4.
// auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
Google,
GitHub,
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.hashedPassword) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token?.id) {
session.user.id = token.id as string;
}
return session;
},
},
});// middleware.ts
import { auth } from "./auth";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
const isOnLogin = req.nextUrl.pathname.startsWith("/login");
if (isOnDashboard && !isLoggedIn) {
return Response.redirect(new URL("/login", req.nextUrl));
}
if (isOnLogin && isLoggedIn) {
return Response.redirect(new URL("/dashboard", req.nextUrl));
}
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Getting the session in a Server Component:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return <div>Welcome, {session.user.name}</div>;
}When Auth.js makes sense:
- You are using Prisma with a PostgreSQL/MySQL database and do not want to add Supabase.
- You need a provider that Supabase does not support (SAML, custom OAuth, specific enterprise IdPs).
- You need the Credentials provider for email/password with your own user table.
- You want a library that has been battle-tested since 2020 with a massive community.
Where Auth.js gets painful:
- The Credentials provider does not support session persistence out of the box. You must use JWT strategy.
- Configuration is verbose compared to Supabase. You manage callbacks, adapters, and provider configs manually.
- You are responsible for your own authorization layer. Auth.js gives you identity, not permissions.
- Type augmentation for custom session fields requires declaration merging, which is awkward.
Clerk — The Premium Option
Clerk is authentication as a service done right. The pre-built components are genuinely good-looking, the dashboard is polished, and the developer experience is smooth. I reach for Clerk when a client needs a production-ready auth system immediately and the budget allows for it.
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher([
"/dashboard(.*)",
"/api/projects(.*)",
]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};// app/dashboard/page.tsx
import { currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const user = await currentUser();
if (!user) {
redirect("/sign-in");
}
return (
<div>
<h1>Welcome, {user.firstName}</h1>
<p>{user.emailAddresses[0]?.emailAddress}</p>
</div>
);
}// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn afterSignInUrl="/dashboard" />
</div>
);
}That is the entire setup. The <SignIn /> component handles email/password, social providers, MFA, and password reset with zero custom code. For client projects where auth is not the differentiator, this saves days of development time.
When Clerk is the right call:
- Client projects where shipping speed matters more than cost.
- Apps that need MFA, organization management, or user impersonation out of the box.
- Teams that do not want to manage auth infrastructure at all.
- Projects where the pre-built UI components fit the design system (or close enough).
Where Clerk falls short:
- Pricing. The free tier is limited to 10,000 monthly active users. After that, you are paying per user.
- Vendor lock-in. Your user data lives on Clerk's servers. Migrating away is a significant effort.
- No RLS integration. You still need to build your own authorization layer.
- Customizing the pre-built components beyond Clerk's theming system is frustrating.
Custom JWT — When You Have To
I have built custom JWT auth exactly twice in production, and both times it was because the project had requirements that no off-the-shelf solution could handle. One was a multi-tenant SaaS with a legacy database. The other was an API-first platform where the auth tokens needed custom claims for a third-party service.
Unless you are in a similar situation, do not do this. You will reinvent problems that Supabase and Auth.js have already solved.
That said, here is a clean implementation for when you genuinely need it:
// lib/auth/jwt.ts
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
interface UserPayload extends JWTPayload {
userId: string;
email: string;
role: "admin" | "user";
}
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function signToken(payload: UserPayload): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
}
export async function signRefreshToken(userId: string): Promise<string> {
return new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret);
}
export async function verifyToken(token: string): Promise<UserPayload> {
const { payload } = await jwtVerify(token, secret);
return payload as UserPayload;
}// lib/auth/session.ts
import { cookies } from "next/headers";
import { verifyToken, signToken, signRefreshToken } from "./jwt";
export async function getSession() {
const cookieStore = await cookies();
const token = cookieStore.get("auth-token")?.value;
if (!token) {
return null;
}
try {
return await verifyToken(token);
} catch {
return null;
}
}
export async function setSession(
userId: string,
email: string,
role: "admin" | "user"
) {
const cookieStore = await cookies();
const accessToken = await signToken({ userId, email, role });
const refreshToken = await signRefreshToken(userId);
cookieStore.set("auth-token", accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 15, // 15 minutes
path: "/",
});
cookieStore.set("refresh-token", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
});
}
export async function clearSession() {
const cookieStore = await cookies();
cookieStore.delete("auth-token");
cookieStore.delete("refresh-token");
}The 15-minute access token / 7-day refresh token pattern is standard. Short-lived access tokens limit the damage window if a token is compromised. The refresh token allows silent re-authentication without forcing users to log in every 15 minutes.
When custom JWT is justified:
- Legacy database integration where adapters do not exist.
- Custom token claims required by third-party services.
- Multi-tenant architectures with complex tenant isolation requirements.
- API-first platforms where the auth system is the product.
Why you should probably avoid it:
- You own every security bug. Token refresh, CSRF protection, session invalidation, password hashing — all on you.
- You will spend weeks building what Supabase gives you in an afternoon.
- Security audits become expensive because the auditor cannot rely on known-good library behavior.
Session Management — Cookies vs Tokens
This deserves its own section because I see developers make the wrong choice constantly.
For web applications: use HttpOnly cookies. Full stop. The session token (whether it is a JWT or a session ID) should live in an HttpOnly cookie with Secure, SameSite=Lax, and a reasonable maxAge. The browser sends it automatically on every request. Your JavaScript code never touches it, which means XSS cannot steal it.
// The correct cookie configuration for production
const cookieOptions = {
httpOnly: true, // JavaScript cannot access it
secure: true, // HTTPS only
sameSite: "lax" as const, // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/",
};For mobile apps or third-party API consumers: use Bearer tokens. Mobile apps cannot use cookies reliably across WebViews and native HTTP clients. Bearer tokens in the Authorization header are the standard.
The hybrid pattern I use most often: Supabase Auth handles this automatically. It stores the session in cookies for the web app and exposes the access token for any API calls that need Bearer auth. You get both without managing either.
// Getting the token for API calls from a client component
import { createClient } from "@/lib/supabase/client";
async function callExternalApi() {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
const response = await fetch("https://api.external-service.com/data", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
return response.json();
}Server-Side Auth in App Router
The App Router changed how authentication works in Next.js. With Server Components as the default, auth checks happen on the server before any HTML is sent to the client. This is more secure than the old Pages Router approach where you would render a loading spinner, check auth on the client, and redirect if unauthorized.
Here is the pattern I use on every project:
// lib/auth/guard.ts
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export async function requireAuth() {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
redirect("/login");
}
return user;
}
export async function requireRole(role: string) {
const user = await requireAuth();
const supabase = await createClient();
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
if (profile?.role !== role) {
redirect("/unauthorized");
}
return user;
}// app/admin/page.tsx
import { requireRole } from "@/lib/auth/guard";
export default async function AdminPage() {
const user = await requireRole("admin");
return <AdminDashboard userId={user.id} />;
}Two important things about requireAuth and requireRole:
- They call
supabase.auth.getUser(), notgetSession(). ThegetUser()method validates the token against the Supabase server on every call. ThegetSession()method only reads the local cookie, which could be tampered with. Always usegetUser()for server-side auth checks.
- They use Next.js
redirect(), which throws a special error that Next.js catches. This means the page component never renders if the user is not authenticated. No flash of unauthorized content.
For layouts that protect multiple pages:
// app/(protected)/layout.tsx
import { requireAuth } from "@/lib/auth/guard";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireAuth();
return <>{children}</>;
}Every page inside app/(protected)/ is now guarded. But remember: layouts do not re-execute on client-side navigation within the same layout group. The middleware is still your first line of defense for route protection. The layout check is a second layer.
Protected API Routes
API Route Handlers in the App Router follow the same pattern. Every route that requires authentication starts with a session check:
// app/api/projects/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 } = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
const { data: projects, error: dbError } = await supabase
.from("projects")
.select("id, name, status, created_at")
.order("created_at", { ascending: false });
if (dbError) {
return NextResponse.json(
{ code: "DB_ERROR", message: "Failed to fetch projects" },
{ status: 500 }
);
}
return NextResponse.json({ data: projects });
}
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
const body = await request.json();
const { data: project, error: insertError } = await supabase
.from("projects")
.insert({
name: body.name,
status: "active",
user_id: user.id,
})
.select()
.single();
if (insertError) {
return NextResponse.json(
{ code: "INSERT_ERROR", message: "Failed to create project" },
{ status: 500 }
);
}
return NextResponse.json({ data: project }, { status: 201 });
}I extract the auth check into a helper to keep route handlers clean:
// lib/auth/api-guard.ts
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
import type { User } from "@supabase/supabase-js";
type AuthResult =
| { user: User; error: null }
| { user: null; error: NextResponse };
export async function requireApiAuth(): Promise<AuthResult> {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return {
user: null,
error: NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
),
};
}
return { user, error: null };
}// app/api/projects/route.ts (cleaner version)
import { requireApiAuth } from "@/lib/auth/api-guard";
export async function GET() {
const { user, error } = await requireApiAuth();
if (error) return error;
// user is guaranteed to be non-null here
// proceed with business logic
}This pattern keeps every route handler focused on its business logic instead of repeating auth boilerplate.
Auth + RLS — The Killer Combo
This is the section I care about most, because this pattern eliminates bugs that no amount of application code can prevent.
Row Level Security (RLS) is a Postgres feature that lets you define policies on who can read, insert, update, or delete rows in a table. When you use Supabase Auth, the authenticated user's ID is available inside these policies through auth.uid(). This means the database itself enforces who can access what data.
Here is a real example from a project management app:
-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Users can only read their own projects
CREATE POLICY "Users can view own projects"
ON projects
FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert projects for themselves
CREATE POLICY "Users can create own projects"
ON projects
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own projects
CREATE POLICY "Users can update own projects"
ON projects
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can only delete their own projects
CREATE POLICY "Users can delete own projects"
ON projects
FOR DELETE
USING (auth.uid() = user_id);With these policies in place, a query like supabase.from('projects').select('*') automatically returns only the current user's projects. You cannot accidentally fetch another user's data. A malicious user tampering with request parameters cannot access data they should not see. The database is the last line of defense, and it never forgets to check.
For more complex scenarios like team-based access:
-- Team members can view projects belonging to their team
CREATE POLICY "Team members can view team projects"
ON projects
FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
-- Only team admins can delete projects
CREATE POLICY "Team admins can delete projects"
ON projects
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id = projects.team_id
AND team_members.user_id = auth.uid()
AND team_members.role = 'admin'
)
);Why RLS changes everything:
Without RLS, every API route, every Server Action, and every query needs to include a WHERE user_id = currentUser.id clause. Miss one, and you have a data leak. With RLS, the default is "no access." You explicitly grant access through policies. The failure mode is "user sees nothing" instead of "user sees everything."
I have found authorization bugs in code reviews where a developer forgot to filter by user ID on a single endpoint. With RLS, that bug is structurally impossible. The query returns zero rows instead of leaking data.
The one caveat: RLS policies need to be performant. A policy that runs a subquery on every row can destroy query performance. Always index the columns used in your policies (like user_id and team_id), and use EXPLAIN ANALYZE to verify that your policies are not creating sequential scans.
My Decision Framework
After shipping projects with every approach, here is the framework I use when starting a new Next.js project:
Start with Supabase Auth if:
- You are using Supabase as your database (obvious choice).
- You are starting a new project and have not chosen a database yet.
- You need auth + authorization in one system.
- You want RLS to prevent authorization bugs at the database level.
- Budget is a concern (the free tier is generous).
Use Auth.js if:
- You already have a PostgreSQL or MySQL database with Prisma.
- You need a provider that Supabase does not support.
- You need the Credentials provider for custom email/password with your own user table.
- You want maximum control and do not mind writing more configuration.
Use Clerk if:
- You need pre-built auth UI components and do not want to design login pages.
- The project has budget for a paid auth service.
- You need organization management, user impersonation, or advanced MFA out of the box.
- Shipping speed matters more than cost or vendor independence.
Build custom JWT auth if:
- You have a legacy database that no adapter supports.
- You need custom token claims for third-party service integration.
- You are building an API-first platform where auth tokens are a product feature.
- You have a security team to audit and maintain the implementation.
For most projects, the answer is Supabase Auth. It handles 90% of use cases with the least amount of code, the strongest security guarantees (thanks to RLS), and the lowest operational overhead. I reach for something else only when Supabase genuinely does not fit.
Key Takeaways
- Supabase Auth + RLS is the strongest default in 2026. Authentication and authorization in one system, enforced at the database level.
- Always use `getUser()` over `getSession()` for server-side auth checks. The session can be tampered with;
getUser()validates against the server.
- Cookies beat localStorage for web app sessions. HttpOnly, Secure, SameSite=Lax. Your JavaScript never touches the token.
- Middleware is your first defense, layout auth is your second, and route-level checks are your third. Defense in depth.
- RLS eliminates authorization bugs structurally. The database denies access by default. You cannot forget a
WHEREclause when the database adds it for you.
- Custom JWT is a last resort. You own every security bug when you roll your own auth. Use it only when off-the-shelf solutions genuinely cannot handle your requirements.
- The App Router made auth simpler. Server Components check auth before rendering. No more loading spinners and client-side redirects.
If you are building a Next.js application and want help choosing the right auth architecture, or need someone to implement it properly, check out my services. I have shipped auth systems for SaaS platforms, e-commerce sites, and internal tools — and I will make sure yours is production-grade from day one.
*Uvin Vindula is a full-stack Web3 and AI engineer based between Sri Lanka and the UK. He builds production-grade applications at iamuvin.com↗ and writes about the patterns that actually work in modern web development. Follow his work at @IAMUVIN↗.*
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.