How to Add Authentication to Any React App in 2026
·PkgPulse Team
TL;DR
Use Clerk for the fastest production auth; Auth.js (NextAuth v5) for Next.js self-hosted; Lucia for custom self-hosted. Clerk handles email, social login, MFA, session management, and UI components in one SaaS. Auth.js is free but you manage the database. Lucia is pure TypeScript with zero opinions — build exactly what you need. The right choice depends on how much control vs convenience you need.
Key Takeaways
- Clerk: fastest to production — pre-built UI, session management, MFA, all included
- Auth.js v5: Next.js native — free self-hosted, database sessions, works with any DB
- Lucia: maximum control — no magic, build auth your way, TypeScript-first
- JWT vs sessions: sessions (DB-backed) are safer; JWT fine for stateless APIs
- Always use HTTPS, HttpOnly cookies, and CSRF protection in production
Option 1: Clerk (Managed Auth)
npm install @clerk/nextjs # Next.js
npm install @clerk/react # React (SPA)
// Next.js App Router setup
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
// middleware.ts — protect routes
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/settings(.*)',
'/api/protected(.*)',
]);
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect();
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
// Usage in components
import { useUser, SignInButton, SignOutButton, UserButton } from '@clerk/nextjs';
function Navbar() {
const { isSignedIn, user } = useUser();
return (
<nav>
{isSignedIn ? (
<>
<span>Hello, {user.firstName}</span>
<UserButton afterSignOutUrl="/" /> {/* Full profile dropdown */}
</>
) : (
<SignInButton />
)}
</nav>
);
}
// Server Component: get session without useUser
import { auth, currentUser } from '@clerk/nextjs/server';
export default async function Dashboard() {
const { userId } = auth();
if (!userId) redirect('/sign-in');
const user = await currentUser();
return <div>Welcome {user?.firstName}</div>;
}
Option 2: Auth.js v5 (Self-Hosted)
npm install next-auth@beta # Auth.js v5 (NextAuth)
// auth.ts — core auth config
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Credentials({
async authorize(credentials) {
const { email, password } = credentials as {
email: string;
password: string;
};
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.hashedPassword) return null;
const valid = await bcrypt.compare(password, user.hashedPassword);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
// Server component usage
import { auth } from '@/auth';
export default async function Page() {
const session = await auth();
if (!session) redirect('/api/auth/signin');
return <div>Hello {session.user.name}</div>;
}
// Client component usage
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
function NavBar() {
const { data: session } = useSession();
return session ? (
<button onClick={() => signOut()}>Sign out {session.user.email}</button>
) : (
<button onClick={() => signIn('github')}>Sign in with GitHub</button>
);
}
Option 3: Lucia (Roll Your Own)
npm install lucia
npm install @lucia-auth/adapter-prisma # Or drizzle, mongoose, etc.
// lib/auth.ts — Lucia setup
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '@/lib/prisma';
import { cookies } from 'next/headers';
export const lucia = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => ({
email: attributes.email,
name: attributes.name,
}),
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: { email: string; name: string };
}
}
// Validate session (use in every protected route)
export async function validateRequest() {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) return { user: null, session: null };
const { user, session } = await lucia.validateSession(sessionId);
try {
if (session?.fresh) {
// Refresh session cookie
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
// Clear expired session cookie
const blankCookie = lucia.createBlankSessionCookie();
cookies().set(blankCookie.name, blankCookie.value, blankCookie.attributes);
}
} catch {
// cookies() can throw in static rendering
}
return { user, session };
}
// Login endpoint with Lucia
// app/api/login/route.ts
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.hashedPassword)) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return Response.json({ success: true });
}
When to Choose
| Scenario | Pick |
|---|---|
| Want auth done in 30 minutes | Clerk |
| Need MFA, social login, enterprise SSO | Clerk |
| Self-hosted, Next.js, OAuth providers | Auth.js v5 |
| Full control over every aspect | Lucia |
| API-only (no UI) | Lucia or roll your own JWT |
| Compliance requires on-prem | Lucia or Auth.js |
| Team new to auth | Clerk (handles security for you) |
Compare authentication package health on PkgPulse.
See the live comparison
View clerk vs. nextauth on PkgPulse →