DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Lucia Auth v3: The Lightweight NextAuth Replacement That Actually Makes Sense

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.
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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 }
})
Enter fullscreen mode Exit fullscreen mode

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() }
  })
}
Enter fullscreen mode Exit fullscreen mode

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!
)
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)