In this post, I want to go over how to integrate NextJS, Lucia Auth, and Drizzle ORM. I used NextAuth.js (previously known as Auth.js) but wanted to try something new and challenging. I saw that there was an open source library to manage handling sessions and that provided helpful APIs. It was Lucia. This is a loooooong post so buckle up.
If you get stuck or you need to refer to the code, check out my repo here
1. Project Structure
📦 <project root>
├ 📂 drizzle
│ ├ 📂 meta
│ │ └ 📜 0000_****_******.sql
├ 📂 src -> using the src/ dir for this project
│ ├ 📂 actions
│ │ └ 📂 authentication
│ │ │ ├ 📜 sign-in.ts
│ │ │ └ 📜 sign-up.ts
│ ├ 📂 app
│ │ └ 📂 (auth)
│ │ │ └ 📂 _component
│ │ │ │ ├ 📜 Login.tsx
│ │ │ │ └ 📜 SignUp.tsx
│ │ └ 📂 dashboard
│ │ │ └ 📜 page.tsx
│ │ │ 📜 globals.css
│ │ │ 📜 layout.tsx
│ ├ 📂 components
│ │ └ 📂 ui
│ │ │ │ └ 📜 shadcn components are stored here
│ ├ 📂 lib
│ │ └ 📂 use-cases
│ │ │ └ 📜 users.ts
│ │ │ 📜 db.ts
│ │ │ 📜 utils.ts
│ │ │ 📜 validate-request.ts
│ ├ 📂 lucia
│ │ └ 📜 auth.ts
│ ├ 📂 schema
│ │ │ 📜 drizzle-schema.ts
│ │ └ 📜 index.ts
├ 📜 .env
└ 📜 drizzle.config.ts
2. NeonDB, Lucia Auth, Drizzle ORM
First, create a NextJS project:
npx create-next-app@latest
Add an .env
file and update the .gitignore
to hide said .env
file.
Let's start with the database, lucia and drizzle ORM logic before working on the UI. First, install the following packages:
npm i drizzle-orm @auth/drizzle-adapter lucia @lucia-auth/adapter-drizzle @neondatabase/serverless postgres uuid @types/uuid
npm i -D drizzle-kit
Create a drizzle.config.ts
file and add the following:
import { defineConfig } from "drizzle-kit"
export default defineConfig({
schema: "./src/schema/drizzle-schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL! // I'm using neon db - you should add the url for the database service you're using. Also, if the database service you're using is not postgres, you should change the "dialect" value.
}
})
You should also add the DATABASE_URL
and the db string for your database
DATABASE_URL=**********
Create a schema file for drizzle:
// you can learn more about the column types in drizzle: https://orm.drizzle.team/docs/column-types/pg
// you can learn more about drizzle schema here: https://orm.drizzle.team/docs/schemas
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
// creating a table to store users
export const userTable = pgTable("admin", {
id: text("id").primaryKey().notNull(),
username: text("user_name").notNull(),
hashedPassword: text("hashed_password").notNull(),
salt: text("salt").notNull()
})
// creating a table to store users' session
export const sessionTable = pgTable("session", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => userTable.id),
expiresAt: timestamp("expires_at", {
withTimezone: true,
mode: "date"
}).notNull()
});
Let's push what we've done to our database service. With drizzle, we can use these commands:
npx drizzle-kit generate
npx drizzle-kit push // after this command, check your db service to see if the tables made in our schema file has been applied.
you can learn more about the commands for drizzle-kit
over here
That's pretty much it for drizzle. Let's move on to creating a db instance.
// learn more from here: https://orm.drizzle.team/docs/get-started-postgresql
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@/schema/drizzle-schema"
const connectionString = process.env.DATABASE_URL!
const client = postgres(connectionString, { prepare: false })
export const db = drizzle(client, { schema });
Great! Now we can work on the lucia auth logic:
// learn more from here: https://lucia-auth.com/basics/sessions
import { db } from "@/lib/db";
import { sessionTable, userTable } from "@/schema/drizzle-schema";
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { Lucia } from "lucia";
const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable);
const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
secure: true
}
},
getUserAttributes: (attributes) => {
return { ...attributes }
}
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
}
export {
lucia // we can use this to create sessions and store cookies
}
After that's all done, let's create a function called validateRequest()
which will return the user
and session
values. We can use these values in our app to protect routes or show logged in user's username (We will go over this below!).
// you can read more about validating session cookies with lucia here: https://lucia-auth.com/guides/validate-session-cookies/nextjs-app
import { cookies } from "next/headers";
import { cache } from "react";
import type { Session, User } from "lucia";
import { lucia } from "@/lucia/auth";
export const validateRequest = cache(
async (): Promise<{ user: User; session: Session } | { user: null; session: null }> => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return {
user: null,
session: null
};
}
const result = await lucia.validateSession(sessionId);
// next.js throws when you attempt to set cookie when rendering page
try {
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {
// you can write your own custom logic to handle errors
}
return result;
}
);
That's pretty much it for the database
, drizzleORM
, and lucia auth
logic. Let's work on the frontend UI.
3. UI and Server Actions
We can now work on code which we can really see (yeepee). Before moving on, let's add some packages for our UI and form validations:
npx shadcn-ui@latest init
...
npx shadcn-ui@latest add button form input label
npm i zod @hookform/resolver
Let's create the login page (this will be the default page for our app):
import { redirect } from "next/navigation";
import { validateRequest } from "@/lib/validate-request";
import Login from "./_component/Login";
export default async function Home() {
const { user } = await validateRequest() // as I said above, we can use the
// "validateRequest()" to check if the user is already logged in or not
if (user) {
return redirect("/dashboard")
}
return (
<div className="min-h-screen w-full flex items-center justify-center">
<Login />
</div>
);
}
Let's work on the Login
component:
"use client"
import { useTransition } from "react";
import Link from "next/link";
import { signIn } from "@/actions/authentication/sign-in";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { LoginUserSchema } from "@/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Loader2Icon } from "lucide-react";
export const Login = () => {
const [isPending, startTransition] = useTransition()
const form = useForm({
resolver: zodResolver(LoginUserSchema),
defaultValues: {
username: "",
password: ""
}
})
const onSubmit = (values: z.infer<typeof LoginUserSchema>) => {
const { password, username } = values
startTransition(() => {
signIn(username, password) // we will create this server action in the next part!
.then((result) => {
// add logic for error and success cases
})
})
}
return (
<div className="w-[500px] border border-black/20 shadow-lg rounded-lg p-4">
<h1 className="text-xl font-bold text-center mt-1">Sign in to ______</h1>
<h2 className="text-sm font-semibold text-center mt-2 text-gray-500">Welcome Back :D</h2>
<div className="mt-5">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="enter username..."
type="text"
className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0"
{...field}
disabled={isPending}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="enter password..."
type="password"
className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0"
{...field}
disabled={isPending}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<Button
className="w-full"
disabled={isPending}
>
{
isPending ?
<>
<Loader2Icon className="w-4 h-4 animate-spin mr-2" /> Login
</>
: "Login"
}
</Button>
</form>
</Form>
</div>
<p className="text-center mt-3 text-xs">Don't have an account? <Link className="underline underline-offset-1 hover:text-gray-600" href="/signup">Sign Up</Link></p>
</div >
)
}
export default Login
We will also use zod for value validations! Let's install the package and add this code in our project:
npm install zod
// you can learn more about zod here: https://zod.dev/
import { z } from "zod";
// this schema will be used with the login page
export const LoginUserSchema = z.object({
username: z.string().min(1, {
message: "Please type in your username"
}),
password: z.string().min(1, {
message: "Please type in your password"
})
})
// this schema will be used with the sign up page
export const RegisterUserSchema = z.object({
username: z.string().min(5, {
message: "Your username should be longer than 5 characters"
}),
password: z.string().min(8, {
message: "Your password should be longer than 8 characters"
})
})
Nice! We can then npm run dev
and check how our app looks like. It's pretty simple but looks great! We can even test how zod validates our values. Try it out!

We can see how zod prevents us from submitting a form when a field has no values in it (because we specified it in the schema above).
Now let's move on to the signup
page. Add the following code:
import { redirect } from "next/navigation";
import { validateRequest } from "@/lib/validate-request";
import SignUp from "../_component/SignUp";
export default async function Home() {
const { user } = await validateRequest()
if (user) {
return redirect("/dashboard")
}
return (
<div className="min-h-screen w-full flex items-center justify-center">
<SignUp />
</div>
);
}
We will now add the following code to our SignUp
component:
"use client"
import { signUp } from "@/actions/authentication/sign-up"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { useForm } from "react-hook-form"
import { useTransition } from "react"
import { z } from "zod"
import { RegisterUserSchema } from "@/schema"
import { zodResolver } from "@hookform/resolvers/zod"
import Link from "next/link"
import { Loader2Icon } from "lucide-react"
export const SignUp = () => {
const [isPending, startTransition] = useTransition()
const form = useForm<z.infer<typeof RegisterUserSchema>>({
resolver: zodResolver(RegisterUserSchema),
defaultValues: {
username: "",
password: ""
}
})
const onSubmit = (values: z.infer<typeof RegisterUserSchema>) => {
const { password, username } = values
startTransition(() => {
signUp(username, password) // we will create this server action in the next part!
.then((result) => {
// if (result?.errors) {
// handle error however you want
// }
// if (success) => {
// handle success case - maybe add a toast message
// }
})
})
}
return (
<div className="w-[500px] border border-black/20 shadow-lg rounded-lg p-4">
<h1 className="text-xl font-bold text-center mt-1">Register</h1>
<h2 className="text-sm font-semibold text-center mt-2 text-gray-500">Hello :D</h2>
<div className="mt-5">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="enter username..."
type="text"
className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0"
{...field}
disabled={isPending}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="enter password..."
type="password"
className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0"
{...field}
disabled={isPending}
/>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
<Button
className="w-full"
disabled={isPending}
>
{
isPending ?
<>
<Loader2Icon className="w-4 h-4 animate-spin mr-2" /> Register
</>
: "Register"
}
</Button>
</form>
</Form>
</div>
<p className="text-center mt-3 text-xs">Already have an account? <Link className="underline underline-offset-1 hover:text-gray-600" href="/">Login</Link></p>
</div >
)
}
export default SignUp
And finally, our dashboard
page.
import { validateRequest } from "@/lib/validate-request"
import { redirect } from "next/navigation"
export default async function Page() {
const { user, session } = await validateRequest()
if (!user) {
return redirect("/")
}
return (
<div>
<p>Hello, {user.username}!</p>
<p>Your session expires in, {session.expiresAt.toString()}</p>
</div>
)
}
We're done with the UI! Now let's work on the server actions and custom functions/logic to handle our login and register features.
Let's work on the signup
feature first.
"use server";
import { registerUserUseCase } from "@/lib/use-cases/users";
import { lucia } from "@/lucia/auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function signUp(username: string, password: string) {
let user
try {
user = await registerUserUseCase(username, password) // we will create this function is just a moment
} catch (err) {
return {
errors: "Please try again"
}
}
// this was the lucia value I talked about above where I said that we could set cookies and sessions for our user.
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
)
return redirect("/dashboard")
}
Let's create the registerUserUseCase
function:
import { db } from "@/lib/db";
import { userTable } from "@/schema/drizzle-schema";
import { eq } from "drizzle-orm";
import crypto from "crypto"
import { v4 as uuidv4 } from "uuid"
// hash password logic
async function hashPassword(plainTextPassword: string, salt: string) {
return new Promise<string>((resolve, reject) => {
crypto.pbkdf2(
plainTextPassword,
salt,
10000,
64,
"sha512",
(err, derivedKey) => {
if (err) reject(err)
resolve(derivedKey.toString("hex"))
}
)
})
}
// register user
export async function registerUserUseCase(username: string, password: string) {
// check for existing user
const existingUser = await db.query.userTable.findFirst({
where: eq(userTable.username, username)
})
if (existingUser) {
// return custom error
}
// if there isn't an existing user, create new one
const salt = crypto.randomBytes(128).toString("base64")
// generate hashed password
const hash = await hashPassword(password, salt)
// create & store user in database
const [user] = await db
.insert(userTable)
.values({
hashedPassword: hash,
username,
salt,
id: uuidv4(),
})
.returning()
return user // using the .returning() returns an array so we destructure the response (like above). Read more about the issue here - https://github.com/drizzle-team/drizzle-orm/issues/1237
}
NOICE! We can now create a new user! Let's try this out (the gif quality is not really good because I compressed it to reduce the file size. It probably won't look like this on your computer or laptop. Also, I only recorded the screen where it contains relevant information rather than recording the entire screen. In your screen, the SignUp
form should be exactly in the center of the web browser screen.):

We can also check and see that the user has been created and stored successfully in the database. Run npx drizzle-kit studio
on your terminal and check.

The expires_at
value is set for 1 month. We can change this accordingly however we want. You can check more about session in lucia auth here.
Now, let's finish this project by adding code for our sign-in
server action:
"use server";
import { signInUseCase } from "@/lib/use-cases/users";
import { lucia } from "@/lucia/auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function signIn(username: string, password: string) {
const user = await signInUseCase(username, password) // we will create this down below
if (!user) {
return {
errors: "Invalid Credentials - Please Try Again!"
}
}
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
)
return redirect("/dashboard")
}
Add the following code in the existing src/lib/use-cases/users.ts
file. Right below the code where we defined the logic for creating and storing new users.
export async function registerUserUseCase() {...}
...
// check if password is correct
async function verifyPassword(username: string, plainTextPassword: string) {
const user = await db.query.userTable.findFirst({
where: eq(userTable.username, username)
})
if (!user) {
return null
}
const salt = user.salt
const savedPassword = user.hashedPassword
if (!salt || !savedPassword) {
return false
}
// compare the salt and hashed password
const hash = await hashPassword(plainTextPassword, salt)
return user.hashedPassword == hash
}
// sign in a user
export async function signInUseCase(username: string, password: string) {
// find a user who matches the username that was inputed in our form
const user = await db.query.userTable.findFirst({
where: eq(userTable.username, username)
})
// if there's no user with said username, return null (or any other custom logic)
if (!user) {
return null
}
// after checking a user exists with that username, check to see if the passwords match
const isPasswordCorrect = await verifyPassword(username, password)
if (!isPasswordCorrect) {
return null
}
return user
}
Nicely done! We can test the login feature now. Delete the session in our db using drizzle-studio
and refresh the page. This will navigate us back to the main page (login page). If we type in our username and password... we can login!

Nicely done! Because the post is already extremely long as it is, I didn't add more content to it. However, you can add more features to this project such as:
- Logout button - create a server action to log out the user. You should also delete the session!
- Toast notifications - add toast notifications for the users so that they know what the custom errors mean
- Shorten the session lifetime value
That's all for this post! It was really challenging at first but felt great when I integrated these technologies. Thanks for reading and see you in the next one :D