Easy App Authentication: Using Lucia Auth, Prisma, and Next.js with Typescript

tasplotasplo
13 min read

Before we dive in, let’s first explore what Lucia Auth is:

Lucia is a TypeScript-based authentication library that simplifies session management. It integrates seamlessly with your database, offering an API that is intuitive, easy to use, and highly extensible.

Setting Up a Next.js Project

Let’s start by creating a Next.js application by running the command below. Use the default options for the prompts. For this tutorial, we will be using TypeScript and Tailwind CSS:

npx create-next-app@latest

Building Authentication Components

We will use shadcn to build our UI components. Let's install it with the following command, leaving all options at their default settings:

npx shadcn@latest init

We will then create login and register pages for our application. Inside your app folder, create two folders: one for login and another for signup. Then, create a page.tsx file inside each of these folders. After that, create a ui folder within the app directory named _ui. Adding an underscore makes this a private folder. This is what my folder structure looks like after all that:

This next version is: 14.2.11

We need to add some components from shadcn to use in our app. Run the command below to add the button component. If the component folder does not exist, shadcn will create it along with the button component:

npx shadcn@latest add button

After that, run the following command to add the input components:

npx shadcn@latest add input

After that, run the following command to add the card components:

npx shadcn@latest add card

After that, run the following command to add the label components:

npx shadcn@latest add label

Inside our _ui folder, create a login-form.tsx file, then enter the code below into login-form.tsx. This is a client component, so we will add "use client" at the top:

"use client";

import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export const description =
    "A login form with email and password. There's an option to login with Github and a link to sign up if you don't have an account.";

export default function LoginForm() {
    return (
        <Card className="mx-auto max-w-sm">
            <CardHeader>
                <CardTitle className="text-2xl">Login</CardTitle>
                <CardDescription>
                    Enter your email below to login to your account
                </CardDescription>
            </CardHeader>
            <CardContent>
                <div className="grid gap-4">
                    <div className="grid gap-2">
                        <Label htmlFor="email">Email</Label>
                        <Input
                            id="email"
                            type="email"
                            placeholder="m@example.com"
                            required
                        />
                    </div>
                    <div className="grid gap-2">
                        <div className="flex items-center">
                            <Label htmlFor="password">Password</Label>
                            <Link
                                href="#"
                                className="ml-auto inline-block text-sm underline"
                            >
                                Forgot your password?
                            </Link>
                        </div>
                        <Input id="password" type="password" required />
                    </div>
                    <Button type="submit" className="w-full">
                        Login
                    </Button>
                    <Button variant="outline" className="w-full">
                        Login with Github
                    </Button>
                </div>
                <div className="mt-4 text-center text-sm">
                    Don&apos;t have an account?{" "}
                    <Link href="/register" className="underline">
                        Sign up
                    </Link>
                </div>
            </CardContent>
        </Card>
    );
}

Inside our _ui folder, create another file register-form.tsx file, then enter the code below into register-form.tsx. This is a client component, so we will add "use client" at the top:

"use client";

import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export const description =
    "A sign up form with first name, last name, email and password inside a card. There's an option to sign up with GitHub and a link to login if you already have an account";

export default function RegisterForm() {
    return (
        <Card className="mx-auto max-w-sm">
            <CardHeader>
                <CardTitle className="text-xl">Sign Up</CardTitle>
                <CardDescription>
                    Enter your information to create an account
                </CardDescription>
            </CardHeader>
            <CardContent>
                <div className="grid gap-4">
                    <div className="grid grid-cols-2 gap-4">
                        <div className="grid gap-2">
                            <Label htmlFor="first-name">First name</Label>
                            <Input id="first-name" placeholder="Max" required />
                        </div>
                        <div className="grid gap-2">
                            <Label htmlFor="last-name">Last name</Label>
                            <Input
                                id="last-name"
                                placeholder="Robinson"
                                required
                            />
                        </div>
                    </div>
                    <div className="grid gap-2">
                        <Label htmlFor="email">Email</Label>
                        <Input
                            id="email"
                            type="email"
                            placeholder="m@example.com"
                            required
                        />
                    </div>
                    <div className="grid gap-2">
                        <Label htmlFor="password">Password</Label>
                        <Input id="password" type="password" />
                    </div>
                    <Button type="submit" className="w-full">
                        Create an account
                    </Button>
                    <Button variant="outline" className="w-full">
                        Sign up with GitHub
                    </Button>
                </div>
                <div className="mt-4 text-center text-sm">
                    Already have an account?{" "}
                    <Link href="/login" className="underline">
                        Sign in
                    </Link>
                </div>
            </CardContent>
        </Card>
    );
}

Import the LoginForm into the page.tsx file in the login folder and the RegisterForm into the page.tsx file in the register folder. Your page.tsx file in the login folder should look like this. The same applies to the register folder with the RegisterForm. The HTML code is just to center it:

import LoginForm from "../_ui/login-form";

export default function Page() {
    return (
        <table className="h-screen w-full align-middle">
            <tbody>
                <tr>
                    <td>
                        <LoginForm />
                    </td>
                </tr>
            </tbody>
        </table>
    );
}

Configuring Lucia Auth & Prisma

We will install Lucia into our application. Run the following command:

npm install lucia

Let's install Prisma in our application by running this command:

npm install prisma

You can now invoke the Prisma CLI by prefixing it with npx:

npx prisma

Next, set up your Prisma ORM project by creating your Prisma Schema file with the following command:

npx prisma init

We will use SQLite as the database for this tutorial. Run the following command to install the Lucia adapter for SQLite:

npm install @lucia-auth/adapter-sqlite

To get started with Prisma Client, install the @prisma/client package:

npm install @prisma/client

We will install cuid to use for generating our ids, run the code below:

npm install cuid

Let's define our user and session models in the schema.prisma file in the prisma folder. We'll use two database models: User for user data and Session for session data. A user can have multiple sessions, so the User model has a one-to-many relationship with the Session model. Each session belongs to one user, so the Session model has a many-to-one relationship with the User model. Add this code to the schema.prisma file:

//schema.prisma

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "sqlite"
    url      = "file:./dev.db"
}

model User {
    id        String    @id @default(cuid())
    sessions  Session[]
    email     String    @unique
    firstName String?
    lastName  String?
    password  String?
}

model Session {
    id        String   @id @default(cuid())
    userId    String
    expiresAt DateTime

    user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}

Apply a database migration with Prisma to create the tables for the User and Session models. Run the following command to create both the user and session tables in the SQLite database:

npx prisma db push

Next, create a file named prisma.ts inside the lib folder and add the following code:

import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
    return new PrismaClient();
};

declare const globalThis: {
    prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

Create another file named auth.ts inside the lib folder. In this file, we will configure Prisma with Lucia and set up a request to check for the session cookie, validate it, and set a new cookie if needed. The validateRequest() function will be used to check if the user visiting our protected page is authenticated. Add this code inside the auth.ts file:

import { Lucia, Session } from "lucia";
import { cache } from "react";
import { User } from "lucia";
import { cookies } from "next/headers";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from "./prisma";

const adapter = new PrismaAdapter(prisma.session, prisma.user);

export const lucia = new Lucia(adapter, {
    sessionCookie: {
        expires: false,
        attributes: {
            secure: process.env.NODE_ENV === "production",
        },
    },
    getUserAttributes: (attributes) => {
        return {
            email: attributes.email,
            firstname: attributes.firstName,
            lastname: attributes.lastName,
        };
    },
});

declare module "lucia" {
    interface Register {
        Lucia: typeof lucia;
        DatabaseUserAttributes: DefaultUser;
    }
}

type DefaultUser = {
    email: string;
    firstName: string;
    lastName: string;
};

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 {}
        return result;
    }
);

Let’s create a folder inside our src directory called types. Inside this folder, create two files: one named login.ts and another named register.ts. The login.ts file will contain the following code:

export type Login = {
    email: string;
    password: string;
};

Inside the register.ts file, add the following code:

export type Register = {
    firstName: string;
    lastName: string;
    email: string;
    password: string;
};

Inside your src/app folder, create an action.ts file. This file will contain user authentication actions like loginUser(), logoutUser(), and registerUser(). Remember to add "use server" at the top. Here is the code for the action.ts file:

"use server";

import { Register } from "@/types/register";
import prisma from "../lib/prisma";
import { Login } from "@/types/login";
import { lucia, validateRequest } from "@/lib/auth";
import { cookies } from "next/headers";

export async function registerUser({ ...form }: Register) {
    try {
        if (
            !form.email ||
            !form.firstName ||
            !form.lastName ||
            !form.password
        ) {
            return {
                error: `all values are required`,
            };
        }

        const userExist = await prisma.user.findFirst({
            where: { email: form.email },
        });

        if (userExist) {
            return {
                error: `user with the email: ${form.email} already exist`,
            };
        }

        const createUser = await prisma.user.create({
            data: {
                id: form.email,
                email: form.email,
                firstName: form.firstName,
                lastName: form.lastName,
                password: form.password,
            },
        });

        if (!createUser) {
            return {
                error: `something went wrong`,
            };
        }

        return {
            success: `user created successfully`,
        };
    } catch (error) {
        return {
            error: "unknown registration error",
        };
    }
}

export async function loginUser({ ...form }: Login) {
    try {
        if (!form.email || !form.password) {
            return {
                error: `all values are required`,
            };
        }
        const userExist = await prisma.user.findFirst({
            where: { email: form.email },
        });

        if (!userExist) {
            return {
                error: `user with the email${form.email} does not exist`,
            };
        }

        // check password match
        if (userExist.password !== form.password) {
            return {
                error: `incorrect user password`,
            };
        }
        const session = await lucia.createSession(userExist.id, {});
        const sessionCookie = lucia.createSessionCookie(session.id);

        cookies().set(
            sessionCookie.name,
            sessionCookie.value,
            sessionCookie.attributes
        );

        return {
            success: true,
        };
    } catch (error) {
        return {
            error: "unknown login error",
        };
    }
}

export async function logoutUser() {
    const { session } = await validateRequest();

    if (!session) {
        return {
            error: "Unauthorized",
        };
    }

    await lucia.invalidateSession(session.id);

    const sessionCookie = lucia.createBlankSessionCookie();
    cookies().set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
    );
}

Let's update our register-form.tsx with the code below:

"use client";

import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { registerUser } from "../action";
import { FormEvent, useState } from "react";
import { Register } from "@/types/register";
import { useRouter } from "next/navigation";

export const description =
    "A sign up form with first name, last name, email and password inside a card. There's an option to sign up with GitHub and a link to login if you already have an account";

export default function RegisterForm() {
    const router = useRouter();
    const [form, setForm] = useState<Register>({
        firstName: "",
        lastName: "",
        email: "",
        password: "",
    });

    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        const result = await registerUser(form);
        if (result?.error) {
            console.log(result.error);
        } else {
            router.refresh();
            router.push("/login");
            return
        }
    };

    return (
        <Card className="mx-auto max-w-sm">
            <CardHeader>
                <CardTitle className="text-xl">Sign Up</CardTitle>
                <CardDescription>
                    Enter your information to create an account
                </CardDescription>
            </CardHeader>
            <CardContent>
                <form className="grid gap-4" onSubmit={(e) => handleSubmit(e)}>
                    <div className="grid grid-cols-2 gap-4">
                        <div className="grid gap-2">
                            <Label htmlFor="first-name">First name</Label>
                            <Input
                                id="first-name"
                                placeholder="Max"
                                required
                                value={form.firstName}
                                onChange={(e) =>
                                    setForm({
                                        ...form,
                                        firstName: e.target.value,
                                    })
                                }
                            />
                        </div>
                        <div className="grid gap-2">
                            <Label htmlFor="last-name">Last name</Label>
                            <Input
                                id="last-name"
                                placeholder="Robinson"
                                required
                                value={form.lastName}
                                onChange={(e) =>
                                    setForm({
                                        ...form,
                                        lastName: e.target.value,
                                    })
                                }
                            />
                        </div>
                    </div>
                    <div className="grid gap-2">
                        <Label htmlFor="email">Email</Label>
                        <Input
                            id="email"
                            type="email"
                            placeholder="m@example.com"
                            required
                            value={form.email}
                            onChange={(e) =>
                                setForm({
                                    ...form,
                                    email: e.target.value,
                                })
                            }
                        />
                    </div>
                    <div className="grid gap-2">
                        <Label htmlFor="password">Password</Label>
                        <Input
                            id="password"
                            type="password"
                            value={form.password}
                            onChange={(e) =>
                                setForm({
                                    ...form,
                                    password: e.target.value,
                                })
                            }
                        />
                    </div>
                    <Button type="submit" className="w-full">
                        Create an account
                    </Button>
                    <Button variant="outline" className="w-full">
                        Sign up with GitHub
                    </Button>
                </form>
                <div className="mt-4 text-center text-sm">
                    Already have an account?{" "}
                    <Link href="/login" className="underline">
                        Sign in
                    </Link>
                </div>
            </CardContent>
        </Card>
    );
}

Also, let’s update login-form.tsx with the code below:

"use client";

import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { Login } from "@/types/login";
import { loginUser } from "../action";

export const description =
    "A login form with email and password. There's an option to login with Github and a link to sign up if you don't have an account.";

export default function LoginForm() {
    const router = useRouter();
    const [form, setForm] = useState<Login>({
        email: "",
        password: "",
    });

    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        const result = await loginUser(form);
        if (result?.error) {
            console.log(result.error);
        } else {
            router.refresh();
            router.push("/profile");
            return
        }
    };

    return (
        <Card className="mx-auto max-w-sm">
            <CardHeader>
                <CardTitle className="text-2xl">Login</CardTitle>
                <CardDescription>
                    Enter your email below to login to your account
                </CardDescription>
            </CardHeader>
            <CardContent>
                <form className="grid gap-4"  onSubmit={(e) => handleSubmit(e)}>
                    <div className="grid gap-2">
                        <Label htmlFor="email">Email</Label>
                        <Input
                            id="email"
                            type="email"
                            placeholder="m@example.com"
                            required
                            value={form.email}
                            onChange={(e) =>
                                setForm({
                                    ...form,
                                    email: e.target.value,
                                })
                            }
                        />
                    </div>
                    <div className="grid gap-2">
                        <div className="flex items-center">
                            <Label htmlFor="password">Password</Label>
                            <Link
                                href="#"
                                className="ml-auto inline-block text-sm underline"
                            >
                                Forgot your password?
                            </Link>
                        </div>
                        <Input
                            id="password"
                            type="password"
                            required
                            value={form.password}
                            onChange={(e) =>
                                setForm({
                                    ...form,
                                    password: e.target.value,
                                })
                            }
                        />
                    </div>
                    <Button type="submit" className="w-full">
                        Login
                    </Button>
                    <Button variant="outline" className="w-full">
                        Login with Github
                    </Button>
                </form>
                <div className="mt-4 text-center text-sm">
                    Don&apos;t have an account?{" "}
                    <Link href="/register" className="underline">
                        Sign up
                    </Link>
                </div>
            </CardContent>
        </Card>
    );
}

I used useRouter() from next/navigation because once a user logs in or registers, we will redirect them to the next page. For login, it will be the profile page (which we haven't created yet), and for registration, it will be the login page. Since this is a client component, we cannot use redirect as it is for the server.

Inside our login folder, let’s update page.tsx with the code below:

import { validateRequest } from "@/lib/auth";
import LoginForm from "../_ui/login-form";
import { redirect } from "next/navigation";

export default async function Page() {
    const { user } = await validateRequest();

    if (user?.email) {
        redirect("/profile");
    }

    return (
        <table className="h-screen w-full align-middle">
            <tbody>
                <tr>
                    <td>
                        <LoginForm />
                    </td>
                </tr>
            </tbody>
        </table>
    );
}

Do the same with the register folder, add this code into page.tsx of register folder:

import { redirect } from "next/navigation";
import RegisterForm from "../_ui/register-form";
import { validateRequest } from "@/lib/auth";

export default async function Page() {
    const { user } = await validateRequest();

    if (user?.email) {
        redirect("/profile");
    }
    return (
        <table className="h-screen w-full align-middle">
            <tbody>
                <tr>
                    <td>
                        <RegisterForm />
                    </td>
                </tr>
            </tbody>
        </table>
    );
}

Once a user is logged in, if they try to visit the register or login page, they will be redirected back to the profile page since they are already authenticated. If not authenticated, the user will be redirected to the login page.

Inside your src/app directory, create a folder named profile and inside that folder, create a file named page.tsx. Then, add the following code:

// src/app/profile/page.tsx
// this is a protected page
import { Button } from "@/components/ui/button";
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
import { logoutUser } from "../action";

export default async function Page() {
    const { user } = await validateRequest();
    if (!user) {
        redirect("/login");
    }
    return (
        <div className="w-full h-screen flex flex-col items-center justify-center">
            <div>
                hello&nbsp; <span className="text-blue-500">{user.email}</span>
            </div>
            <form action={logoutUser}>
                <Button variant={`destructive`} type="submit" className="mt-5">
                    Logout
                </Button>
            </form>
        </div>
    );
}

You can go ahead and run the application to see if it works with the command:

npm run dev

Conclusion

This guide shows how to set up authentication in a Next.js application using Lucia Auth and Prisma. It covers creating the project, building UI components for login and registration with Shadcn, configuring Lucia Auth with Prisma, defining user and session models, and implementing authentication actions. The process includes ensuring smooth redirects based on authentication status and setting up necessary routes and components for user interactions.

Next, we'll have a tutorial on next-auth using Drizzle and Postgres. It will include password reset, email validation, social login, and email confirmation using a code. What other new features would you like to see? Let me know in the comments below! 👇

This code is available on github: check here

1
Subscribe to my newsletter

Read articles from tasplo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

tasplo
tasplo

Crafting sleek, responsive web apps with code and creativity