Authentication is a critical component of any web application, and choosing the right authentication library can significantly impact your development experience. In the Next.js ecosystem, two popular options have emerged: Auth.js (formerly NextAuth.js) and Lucia Auth.
This article will provide a practical, code-based comparison of the basics of these libraries to make it easy for you to pick which one is best suited for your Next.js project. This article assumes you have:
Auth.js, formerly known as NextAuth.js, is a feature-rich solution that has been a staple in the Next.js community for years. It offers a wide range of built-in providers and a plugin system for extensibility. It was created to simplify the implementation of authentication in web applications, with a focus on ease of use and quick setup.
Auth.js follows a batteries-included paradigm that aims to make authentication as simple as possible for developers. The paradigm abstracts away much of the complexity of authentication, allowing developers to set up secure authentication flows with minimal code.
In contrast, Lucia is an authentication library designed to be simple and flexible, with a focus on type safety and adaptability to various frameworks and databases. Lucia follows a minimalist and flexible paradigm, providing building blocks to handle the most tedious aspects of authentication while leaving the implementation details to you, allowing you a much greater degree of flexibility and control.
The primary feature difference between these two libraries is that Auth.js supports both sessions stored in a database and sessions based on JWTs (JSON Web Tokens). Meanwhile, all Lucia sessions must be stored in a database. Additionally, when authenticating with a username and password, i.e., credentials, Auth.js doesn’t support using database sessions. Here is a quote from their official docs:
The Credentials provider only supports the JWT session strategy. You can still create and save a database session and reference it from the JWT via an id, but you’ll need to provide that logic yourself.
In this section, we’ll get a better feel for the differences between Auth.js and Lucia by comparing code examples of them in action. The core of both Lucia and Auth.js is session management, so we’ll look at what it takes to initialize each library and their approaches to creating, accessing, and invalidating user sessions.
Lucia requires a database, so when you install it, you’ll also need to install a database driver and a matching Lucia adapter. Once you have a driver and adapter installed, you can initialize Lucia with the Lucia
constructor, which takes your database adapter as the first argument and a configuration object as its second.
Here’s how you might initialize Lucia with the better-sqlite
driver and matching adapter:
import { Lucia } from "lucia";a import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite"; import sqlite from "better-sqlite3"; //initialize the db driver export const db = sqlite("./demo.db"); //initialize the adapter const adapter = new BetterSqlite3Adapter(db, { // lets you decide which tables in the db you want to access and what names to give them user: "user", session: "session", }); export const lucia = new Lucia(adapter, { sessionCookie: { // this sets cookies with super long expiration // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages expires: false, attributes: { // set to `true` when using HTTPS secure: process.env.NODE_ENV === "production", }, }, // this function lets you specify which columns of your user table // you'll be able to access inside your app // by default, Lucia doesn't expose any columns getUserAttributes: (attributes) => { return { username: attributes.username, }; }, });
Now that Lucia has been initialized, we can use the lucia
object to manage user sessions. With Lucia, session creation is explicit: after you’ve verified/registered a user’s credentials, you create a session like this:
const session = await lucia.createSession(userId, {}); const sessionCookie = lucia.createSessionCookie(session.id); cookies().set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes );
The first line creates a record in the sessions table of your database that contains a generated session ID, the ID of the authorized user, and the session’s expiration date. The second line generates the data for a cookie representing the session. And the last statement uses Next.js’ cookie
utility to create the cookie.
Now that you’ve created a session and stored a reference to it inside a cookie, you can access it later using code like this:
const session = cookies().get(lucia.sessionCookieName); if (!session) { return } const sessionId = session.value; if (!sessionId) { return } const result = await lucia.validateSession(sessionId); return result;
First, you get the session ID from the cookie you created before, then you call the lucia.validateSession
method with the session ID as an argument. The lucia.validateSession
method returns an object with two properties: session
and user
.
If validateSession
decides that the session ID passed to it is valid, the value of the session
property will be an object that contains session information, and the user
property will contain the data you exposed in the getUserAttributes
method when you initialized Lucia. If the session ID you pass is invalid, both the session
and user
properties will be null
.
Lucia extends the expiration date of a session whenever it’s used, so you don’t need to worry about that. Finally, you can invalidate a session with the following code:
await lucia.invalidateSession(sessionId);
When you invalidate a session, you should delete the session cookie that references it by overwriting it with a blank cookie like so:
const sessionCookie = lucia.createBlankSessionCookie(); cookies().set( sessionCookie.name, sessionCookie.value, sessionCookie.attributes );
Now, let’s look at Auth.js.
Auth.js doesn’t support database sessions for credentials-based authentication, so we won’t worry about them here. The setup for Auth.js comes down to creating an auth.js
config file and a /app/api/auth/[...nextauth]/route.js
route handler. The route handler should contain this code:
export const { GET, POST } = handlers
And auth.js
should contain this code:
import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ Credentials({ // You can specify which fields should be submitted, by adding keys to the `credentials` object. // e.g. domain, username, password, 2FA token, etc. credentials: { email: {}, password: {}, }, authorize: async (credentials) => { let user = null // logic to verify if the user exists user = await getUserFromDb(credentials.email, password) if (!user) { throw new Error("User not found.") } // return user object return user }, }), ], })
And that’s it for Auth.js. The details of creating the sessions, managing them, and storing references to them are almost entirely handled for you by the functions the NextAuth
method returns. To sign in an authorized user, this is all the code you need:
import { signIn } from "@/auth" async function signUserIn (formData) { "use server" await signIn("credentials", formData) }}
The signOut
method handles session invalidation and the auth
method provides you access to the data stored inside the session.
In this article, we explored the key differences between Auth.js and Lucia Auth, focusing on their features and design paradigms. Auth.js provides a streamlined solution that lets you get up and running quickly, while Lucia offers a minimal toolkit that emphasizes greater customization.
At the time of writing, Lucia has a nearly-empty Issues page on GitHub, indicating a bug-free experience. On the other hand, Auth.js has hundreds, many of them appearing to be edge-case bugs related to less popular providers. Some bugs lack timely support from the maintainers, leading Next.js users to instead rely on community-submitted workarounds and unmerged PRs.
If you’ve used Lucia or Auth.js before, which do you prefer and why? Let us know in the comments below!
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]