NextAuth (now Auth.js) is powerful but notoriously difficult to customize. Session storage is opaque. Database adapters are inconsistent. Extending the session type requires merging module declarations that break across minor versions.
Lucia v3 takes a different approach: it gives you primitives, not abstractions.
What Lucia Is (and Isn't)
Lucia doesn't handle OAuth flows, email/password hashing, or session storage for you. It gives you typed session and user objects, a session lifecycle API, and CSRF protection — then gets out of the way.
You write the SQL. You call the bcrypt. Lucia wires the session cookies.
This feels like more work until you need to customize anything. Then it's clearly less.
Setup With Next.js App Router
npm install lucia arctic
npm install @lucia-auth/adapter-drizzle # or prisma, mongoose, etc.
arctic is the companion OAuth library — thin wrappers around OAuth2 providers.
Define Your Auth Instance
// lib/auth.ts
import { Lucia } from 'lucia'
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle'
import { db } from './db'
import { sessions, users } from './schema'
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users)
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production'
}
},
getUserAttributes(attrs) {
return {
email: attrs.email,
role: attrs.role
}
}
})
declare module 'lucia' {
interface Register {
Lucia: typeof lucia
DatabaseUserAttributes: { email: string; role: string }
}
}
Database Schema (Drizzle)
// lib/schema.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
hashedPassword: text('hashed_password'),
role: text('role').notNull().default('user')
})
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
})
Validate Sessions in Server Components
// lib/auth-utils.ts
import { cookies } from 'next/headers'
import { lucia } from './auth'
import { cache } from 'react'
export const validateRequest = cache(async () => {
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) {
const cookie = lucia.createSessionCookie(session.id)
cookies().set(cookie.name, cookie.value, cookie.attributes)
}
if (!session) {
const cookie = lucia.createBlankSessionCookie()
cookies().set(cookie.name, cookie.value, cookie.attributes)
}
} catch {}
return { user, session }
})
Wrapping in cache() means validateRequest() only hits the database once per request, even if called from multiple server components.
Email/Password Sign Up
// app/api/auth/signup/route.ts
import { hash } from '@node-rs/argon2'
import { generateId } from 'lucia'
export async function POST(request: Request) {
const { email, password } = await request.json()
const hashedPassword = await hash(password, {
memoryCost: 19456,
timeCost: 2,
parallelism: 1
})
const userId = generateId(15)
await db.insert(users).values({ id: userId, email, hashedPassword })
const session = await lucia.createSession(userId, {})
const cookie = lucia.createSessionCookie(session.id)
return new Response(null, {
status: 201,
headers: { 'Set-Cookie': cookie.serialize() }
})
}
Argon2 is the recommended hasher — not bcrypt. The memory/time/parallelism params are Lucia's recommended secure defaults.
GitHub OAuth With Arctic
// lib/oauth.ts
import { GitHub } from 'arctic'
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!
)
// app/api/auth/github/route.ts
import { generateState } from 'arctic'
import { github } from '@/lib/oauth'
export async function GET() {
const state = generateState()
const url = await github.createAuthorizationURL(state, { scopes: ['user:email'] })
const response = new Response(null, { status: 302 })
response.headers.set('Location', url.toString())
response.headers.append('Set-Cookie', `github_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`)
return response
}
// app/api/auth/github/callback/route.ts
import { github } from '@/lib/oauth'
import { lucia } from '@/lib/auth'
export async function GET(request: Request) {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
// validate state from cookie, exchange code for tokens
const tokens = await github.validateAuthorizationCode(code!)
// fetch GitHub user, upsert to DB, create Lucia session
}
Protecting Routes With Middleware
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const PROTECTED = ['/dashboard', '/settings', '/api/user']
export async function middleware(request: NextRequest) {
const isProtected = PROTECTED.some(p => request.nextUrl.pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
const sessionId = request.cookies.get(lucia.sessionCookieName)?.value
if (!sessionId) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
Full session validation in middleware is expensive at the edge — check the cookie exists there, validate the full session in the server component or API route.
Why Not Auth.js?
Auth.js is the right call if you need 50+ OAuth providers and don't want to write callbacks. Lucia is better when:
- You need custom session data (roles, org IDs, permissions)
- You want SQL you control, not an opaque adapter
- You're building multi-tenant and need per-tenant session logic
- You've fought with NextAuth's module augmentation and lost
Building a SaaS with auth, billing, and AI? The AI SaaS Starter Kit ships with Lucia v3 fully wired: GitHub OAuth, email/password, role-based access, and Stripe checkout — all typed end-to-end.
Top comments (0)