Skip to main content

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

ScenarioPick
Want auth done in 30 minutesClerk
Need MFA, social login, enterprise SSOClerk
Self-hosted, Next.js, OAuth providersAuth.js v5
Full control over every aspectLucia
API-only (no UI)Lucia or roll your own JWT
Compliance requires on-premLucia or Auth.js
Team new to authClerk (handles security for you)

Compare authentication package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.