Handling authentication is a complicated yet crucial task. Recognizing the importance of a robust and secure authentication system, Lucia can be an excellent solution for your next project. As a lightweight auth library specifically designed for TypeScript, Lucia abstracts the intricacies of managing users and sessions, making the developer’s job significantly easier.
This article aims to guide you through the process of implementing password-based authentication in your Next.js application, using Lucia as the authentication library. We’ll also use MongoDB, which perfectly complements the tech stack.
The complete code for this tutorial can be found in this GitHub repo. Let’s get started.
Lucia is an authentication library written in TypeScript that can be extended for use with any framework. Lucia works with the database of your choice and provides you with an easy-to-use API that can be extended depending on your use case. Currently, it provides database adapters for libSQL, Cloudflare D1, Mongoose, PlanetScale serverless, Prisma, and more.
Lucia depends on three tables:
id
, that’ll be used as a primary key, but you can add fields depending on your preferenceid
, user_id
, active_expires
, and idle_expires
. The id
here represents the session ID, and it cannot be an auto-incremented integer, but can be a UUID, and you can add other fields to the session table as neededid
, user_id
, hashed_password
Lucia’s documentation about the tables could be a great starting point for you if you want to dive more into the details.
Now that you have a basic understanding of Lucia, let’s get started on using it to implement authentication for a Next.js application.
Next.js is one of the most popular React frameworks right now. We’ll use the latest version of Next.js, v 14, and the app router setup for this tutorial.
As a prerequisite, you should be familiar with the following tools and technologies:
The first step is to scaffold a new application and install the necessary dependencies.
The Next.js CLI is a feature-packed solution for creating a new Next.js application. To start the process, open up a folder from your favorite text editor, and run the following command from the terminal:
npx create-next-app@latest your_app_name
Replace your_app_name
with a name of your choice to name the project. The CLI will now prompt you with a few questions. Based on your choices, it’ll create a new Next.js project You can choose the options according to the options shown below:
√ Would you like to use TypeScript? ... Yes √ Would you like to use ESLint? ... Yes √ Would you like to use Tailwind CSS? ... Yes √ Would you like to use `src/` directory? ... Yes √ Would you like to use App Router? (recommended) ... Yes √ Would you like to customize the default import alias (@/*)? ... No
Once the installation succeeds, you are ready to add the necessary dependencies. For this tutorial, only three packages are required:
To install these packages, run the following command from your terminal:
npm i lucia @lucia-auth/adapter-mongoose mongoose
After the installation succeeds, you can start integrating your application with Lucia.
For this tutorial, we’ll use a local instance of MongoDB, but you can connect with MongoDB Atlas as well.
As already mentioned, Lucia requires three tables, so you’ll need to create three models in MongoDB for all of the tables. Create a new folder called lib
inside the src
directory, and create a file called models.ts
inside it. Paste the following code into this file:
import mongoose from "mongoose"; const Schema = mongoose.Schema; const userSchema = new Schema({ _id: { type: String, }, email: { type: String, }, name: { type: String, }, }); const keySchema = new Schema({ _id: { type: String, required: true, }, user_id: { type: String, required: true, }, hashed_password: String, }); const sessionSchema = new Schema({ _id: { type: String, required: true, }, user_id: { type: String, required: true, }, email: { type: String, }, active_expires: { type: Number, required: true, }, idle_expires: { type: Number, required: true, }, }); export const User = mongoose.models.User ?? mongoose.model("User", userSchema); export const Key = mongoose.models.Key ?? mongoose.model("Key", keySchema); export const Session = mongoose.models.Session ?? mongoose.model("Session", sessionSchema);
The code here defines three different schemas — userSchema
, keySchema
, and sessionSchema
— and models based on these schemas. The userSchema
here defines a structure for user data with three fields: _id
, email
, and name
. However, only the _id
here is necessary; all the other fields are optional.
The keySchema
defines a structure for storing keys with three fields: _id
, user_id
, and hashed_password
. The _id
and user_id
fields are required.
Finally, sessionSchema
stores session data, containing fields like _id
, user_id
, email
, active_expires
, and idle_expires
. The active_expires
and idle_expires
fields are numbers and required.
The last three lines create models (User
, Key
, Session
) based on the respective schemas. We use the ??
operator to avoid recompiling models if they already exist in the mongoose.models
object. This is particularly useful in environments like serverless functions, where code may be executed multiple times and lead to multiple attempts at recompiling models, which Mongoose doesn’t allow.
We’ve created the models for Lucia, so let’s write a function to connect with MongoDB. Create another file called db.ts
inside the lib
folder and paste the following code inside it:
import mongoose from "mongoose"; const connectDB = async () => { try { const conn = await mongoose.connect( (process.env.MONGO_URI as string) || "mongodb://localhost:27017/lucia" ); console.log(`MongoDB Connected: ${conn.connection.host}`); } catch (err) { console.error(err); process.exit(1); } }; export default connectDB;
The MONGO_URI
value should be stored inside a .env
file. This represents the connection string for MongoDB. The connectDB
function uses the mongoose.connect
method to connect with MongoDB. Once connected, it will display a message in the console. If any errors occur during this phase, the catch
block catches the error, logs it, and exits the program.
The models and the function to connect to the database are now ready. Let’s initialize Lucia now.
It is necessary to create an instance of Lucia to use it in your project. Under the src
directory, create a new folder called auth
and a file called lucia.ts
inside it. Paste the following code in it:
// auth/lucia.ts import { lucia } from "lucia"; import { nextjs_future } from "lucia/middleware"; import { mongoose } from "@lucia-auth/adapter-mongoose"; import { User, Key, Session } from "@/lib/models"; import connectDB from "@/lib/db"; connectDB(); export const auth = lucia({ env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", middleware: nextjs_future(), sessionCookie: { expires: false, }, adapter: mongoose({ User, Key, Session, }), }); export type Auth = typeof auth;
The code above is a TypeScript module that sets up an authentication middleware using the lucia
library. The code begins by importing the necessary dependencies, which include:
lucia
: Creates the authentication middlewarenextjs_future
: Contains middleware from the lucia/middleware
modulemongoose
: An adapter imported from the @lucia-auth/adapter-mongoose
module to connect with MongoDBUser
, Key
, and Session
: Models imported from the module you created earlierconnectDB
: The function you created earlier for connecting with MongoDBAfter importing the necessary dependencies, the code calls the connectDB
function, which is responsible for establishing a connection to the database. The next step is to create the authentication middleware using the lucia
function, which takes an object as its argument and configures various middleware options.
The options provided for the lucia
function include the following:
env
: Determines the environment in which the code is running; checks the value of the NODE_ENV
environment variable and sets it to either DEV
or PROD
, accordinglymiddleware
: Specifies the middleware to be used. In this case, it uses the nextjs_future
middlewaresessionCookie
: Configures the session cookie and, in our code, sets the expires
property to false
, indicating that the session cookie does not expireadapter
: Specifies the adapter we want to use for authentication. In this case, it uses the mongoose
adapter and provides the User
, Key
, and Session
models as arguments.Finally, the code exports the auth
object, representing the configured authentication middleware. The typeof auth
type declaration is also provided, allowing other parts of the codebase to reference the type of the auth
object as Auth
.
Now that the middleware is ready, you can move forward to building the APIs required for the authentication.
We’ll require three API endpoints:
/api/signup
: To create an account/api/login
: To log in/api/logout
: To logLet’s start by creating the signup endpoint. Inside the app
directory, create a new folder called api
to hold all the necessary APIs. Within it, create a new folder called signup
, and within that, create a file called route.ts
. Paste the following code inside it:
import { auth } from "@/auth/lucia"; import * as context from "next/headers"; import { NextRequest, NextResponse } from "next/server"; export const POST = async (req: NextRequest) => { try { const data = await req.json(); const { email, password, name, role } = data; const user = await auth.createUser({ key: { providerId: "email", providerUserId: email, password, }, attributes: { email, name, role, }, }); const session = await auth.createSession({ userId: user.userId, attributes: {}, }); const authRequest = await auth.handleRequest(req.method, context); authRequest.setSession(session); return new Response( JSON.stringify({ message: "User created", }), { status: 201, } ); } catch (e: any) { return NextResponse.json( { error: "An unknown error occurred", }, { status: 500, } ); } };
Let’s discuss the code now. The POST
handler handles a post request to the route /api/signup
. First, we import the the auth
module we created in the last section. Then, we import context
from next/headers
to handle HTTP headers.
Inside the POST
function, the following steps are performed:
req.json()
email
, password
, and name
properties from the parsed JSON dataauth.createUser()
methodemail
and name
fields are used as the attributesauth.createSession()
method using the user’s userId
auth.handleRequest()
method and the request method and context are passed as argumentssetSession()
methodIf all the above steps are successful, a new Response
object is returned with a status of 201 and a message indicating that the user was created. If any errors occur during execution, a NextResponse
object is returned with a status of 500 and an error message.
You have successfully created the signup route. Let’s move on to building the login route.
Inside the api
folder, create a new folder called login
and a file called route.ts
inside it. This file will be used to handle the login functionality. The code for this file is shown below:
import { auth } from "@/auth/lucia"; import * as context from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { LuciaError } from "lucia"; export const POST = async (req: NextRequest) => { try { const data = await req.json(); const { email, password } = data; const key = await auth.useKey("email", email.toLowerCase(), password); const session = await auth.createSession({ userId: key.userId, attributes: {}, }); const authRequest = await auth.handleRequest(req.method, context); authRequest.setSession(session); return new Response( JSON.stringify({ message: "User logged in", }), { status: 200, } ); } catch (e: any) { if ( e instanceof LuciaError && (e.message === "AUTH_INVALID_KEY_ID" || e.message === "AUTH_INVALID_PASSWORD") ) { // user does not exist or invalid password return NextResponse.json( { error: "Incorrect username or password", }, { status: 400, } ); } return NextResponse.json( { error: "An unknown error occurred", }, { status: 500, } ); } };
Similar to the signup route, the POST
handler handles the login functionality. The modules imported in the signup route are also imported here. In addition to all the previous modules, we’ll also import a LuciaError
module for throwing errors.
The POST
function does the following:
req.json()
email
and password
properties from the parsed JSON dataauth.useKey()
method, which checks their provided email and passwordauth.createSession()
method using the userId
auth.handleRequest()
method; the request method and context are passed as argumentssetSession()
methodIf all the above steps are successful, a new Response
object is returned with a status of 200 and a message indicating that the user has logged in. If any error occurs during the execution of the above steps, a NextResponse
object is returned with a status of 500 and an error message. If the error is a LuciaError
and the message indicates an invalid key ID or password, a more specific error message is returned with a status of 400.
Your login route is also ready now. Let’s create the logout route. This one is pretty straightforward.
Create a new folder called logout
inside the API directory. Create a new route.ts
file and paste the following code into it:
import { auth } from "@/auth/lucia"; import * as context from "next/headers"; import type { NextRequest } from "next/server"; export const POST = async (request: NextRequest) => { const authRequest = auth.handleRequest(request.method, context); // check if user is authenticated const session = await authRequest.validate(); if (!session) { return new Response(null, { status: 401, }); } // make sure to invalidate the current session! await auth.invalidateSession(session.sessionId); // delete session cookie authRequest.setSession(null); return new Response( JSON.stringify({ message: "User logged out", }), { status: 302, } ); };
We import the same modules here, and inside the POST
handler, we perform these steps:
auth.handleRequest()
method; the request method and context are passed as argumentsvalidate()
method on the authentication request to check if the user is authenticated. If the user is not authenticated, a new Response
object is returned with a status of 401
auth.invalidateSession()
method, and the session ID is passed as an argument to this methodsetSession()
method on the authentication request with null
as the argumentResponse
object is returned with a status of 302 and a message indicating that the user has logged outNow that the API routes are created, let’s add the the frontend components.
Let’s create the signup page. Inside your app
folder, create a new folder called signup
and create a page.tsx
file inside it. To keep this article simple, we won’t create separate components or use server components. Instead, all your signup logic will be inside the page.tsx
file only and the page will be converted to a client component using the "use client"
directive.
Inside this file, paste the following code:
"use client"; import React, { useState } from "react"; import { useRouter } from "next/navigation"; export default function SignUp() { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const router = useRouter(); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); try { const response = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password, name }), }); if (response.status === 201) { router.push("/"); } else { setError("Failed to sign up"); } } catch (error) { setError("Failed to sign up"); } }; return ( <div className="h-screen"> <div> <form onSubmit={handleSubmit} className="bg-white p-6 rounded-md shadow-md max-w-lg mx-auto mt-40" > <h3 className="text-2xl font-bold mb-4 text-center text-gray-700"> Sign up </h3> <div className="mb-4"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2" > Full Name </label> <input type="text" id="name" value={name} onChange={(event) => setName(event.target.value)} required className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div className="mb-4"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2" > Email </label> <input type="email" id="email" value={email} onChange={(event) => setEmail(event.target.value)} required className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div className="mb-4"> <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2" > Password </label> <input type="password" id="password" value={password} onChange={(event) => setPassword(event.target.value)} required className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && <div className="mb-4 text-red-500">{error}</div>} <button type="submit" className="bg-blue-500 text-white p-2 w-full rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500" > Sign up </button> </form> </div> </div> ); }
The code here is pretty straightforward. At the top, we import the necessary modules. The useState
React Hook is used to manage local state variables, and useRouter
from Next.js is used to handle navigation. The SignUp
function declares several state variables using the useState
hook: email
, name
, password
, and error
. These variables are used to store the user’s input and any error messages.
An asynchronous function called handleSubmit
is created to handle the form submission. It attempts to send a POST
request to the /api/signup
endpoint with the user’s input as the request body. If the request is successful and returns a status of 201, the user is redirected to the home page. If the request fails, an error message is set with the help of the error state. These requests are made using the Fetch API in JavaScript.
The form inside the JSX includes fields for the user’s name, email, and password. Each field is bound to its corresponding state variable and updates that variable whenever its value changes. The form also displays any error messages and includes a submit button.
The form’s onSubmit
prop is set to the handleSubmit
function, so this function will be called whenever the user submits the form. The form is styled using basic Tailwind CSS styles.
At this point, the signup page should look like the image shown below:
Similarly, create another folder called login
inside the app
directory and a file called page.tsx
inside the login
folder. Paste the following code into it:
"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; const LoginPage = () => { const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); try { const response = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email, password }), }); if (response.status === 200) { router.push("/"); } else { setError("Failed to log in"); } } catch (error) { console.log(error); setError("Failed to log in"); } }; return ( <div className="h-screen"> <form onSubmit={handleSubmit} className="bg-white p-6 rounded-md shadow-md max-w-lg mx-auto mt-40" > <h3 className="text-2xl font-bold mb-4 text-center text-gray-700"> Log In </h3> <div className="mb-4"> <label htmlFor="loginEmail" className="block text-sm font-medium text-gray-700 mb-2" > Email: </label> <input type="email" id="loginEmail" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div className="mb-4"> <label htmlFor="loginPassword" className="block text-sm font-medium text-gray-700 mb-2" > Password: </label> <input type="password" id="loginPassword" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && ( <p className="text-red-500 text-sm my-4 font-bold">{error}</p> )} <button type="submit" className="bg-blue-500 text-white p-2 w-full rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500" > Login </button> </form> </div> ); }; export default LoginPage;
The code here is the same as it was in the earlier signup page. The primary difference is the API endpoint, where the form sends the request. In this case, it is the /api/login
endpoint. Here’s a screenshot of the login page:
Your authentication system is now almost ready. Let’s update the code for the app/page.tsx
file. But before updating the code, let’s create a component to handle the log-out functionality.
Under the app
directory, create a new file called LogOutComponent.tsx
. Again, keeping all your pages and components inside the same directory is not ideal, but to simplify this tutorial, we are keeping everything in the same directory.
Paste the following code into this newly created file:
"use client"; import React from "react"; import { useRouter } from "next/navigation"; export default function LogOutComponent() { const router = useRouter(); const handleLogout = async () => { await fetch("/api/logout", { method: "POST", headers: { "Content-Type": "application/json", }, }); router.push("/login"); }; return ( <div> <button onClick={handleLogout}>Log Out</button> </div> ); }
A handleLogout
function is defined in this code as an asynchronous function that performs the logout operation. Inside this function, we perform the following steps:
POST
request is made to the /api/logout
endpoint using the fetch
function. This is where the server-side logout operation is performedfetch
operation, the router.push
method is called with /login
as the argument, which redirects the user to the login page after they have logged outbutton
element with an onClick
event handler set to the handleLogout
function is created: when the button is clicked, the handleLogout
function is called, performing the logout operation and redirecting the user to the login pageNow, change the code of the /app/page.tsx
file with the following:
import { auth } from "@/auth/lucia"; import * as context from "next/headers"; import { redirect } from "next/navigation"; import LogOutComponent from "./LogOutComponent"; export default async function Home() { const authRequest = auth.handleRequest("GET", context); const session = (await authRequest.validate()) ?? null; if (!session) redirect("/login"); return ( <main className="min-h-screen py-2 max-w-5xl mx-auto"> {session && ( <div> <h1>Home</h1> <code>{JSON.stringify(session)}</code> <LogOutComponent /> </div> )} </main> ); }
In the above code, we import the auth
module from @/auth/lucia
to validate authentication requests. Then, we import the context
module from next/headers
to handle HTTP headers in Next.js. We also import the redirect
function from "next/navigation"
to redirect the user to a different route. Finally, we import the LogOutComponent
.
Inside the function, we create an authentication request using the auth.handleRequest()
method. The request method and context are passed as arguments. The validate()
method is called on the authentication request to check if the user is authenticated. If the user is not authenticated, the redirect()
function redirects the user to the login page. Finally, the session information is stored in the session
variable if the user is authenticated. The JSX part displays the session information and a button to log out.
Your authentication system is now ready. You can test it out by first signing up by visiting the /signup
page and then logging in using the login
route. The complete code for this tutorial can be found in this GitHub repo.
The article aimed to help you quickly get started with Lucia for authentication. This TypeScript library makes it very simple to integrate authentication into your application. To learn more about Lucia, you can visit the official documentation.
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.
Would you be interested in joining LogRocket's developer community?
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 nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.