Step-by-Step Guide to Fullstack Development with Next.js and Lucia Authentication

Introduction

Authentication and authorization are crucial components of web security, ensuring that users can securely access resources within an application. Authentication is the process of verifying a user’s identity, while authorization determines what authenticated users are allowed to do.

In this article, we will focus on implementing email and password authentication using Lucia Auth. This method remains one of the most common ways to authenticate users, requiring them to provide a registered email and a matching password. Lucia Auth simplifies this process by offering a straightforward, secure way to manage user credentials, handle sessions, and protect your application against unauthorized access.

Understanding Lucia

Lucia Auth is a streamlined, open-source authentication library built specifically for TypeScript projects. It aims to offer a flexible and straightforward approach to managing user authentication and sessions on the server. Compared to more feature-rich libraries like Auth.js (formerly NextAuth), Lucia is less intrusive, providing developers with greater control over how they implement their authentication processes.

Key Features

  • Session Management: Lucia handles user sessions, making it easier to validate and manage user authentication on the server side.

  • Versatile Database Compatibility: The library works seamlessly with various databases and ORMs, allowing for flexible integration into different back-end environments.

  • OAuth Support: While Lucia can integrate with OAuth for social logins, it requires more manual setup, offering greater customization compared to other solutions.

  • Customizable Authentication Flows: Lucia is designed to be adaptable, giving developers the tools to create tailored authentication systems that fit specific needs.

  • Server-Side Focus: Lucia operates entirely on the server, requiring developers to manually pass session data to the client, which can be beneficial for certain security models.

Prerequisites

Ensure you have the following installed:

  • Next.js 14

  • Prisma

  • Tailwind CSS

  • React Hook Form

  • Zod

  • Argon2

  • Lucia Auth

  • Shadcn UI components

Project Setup

Create a new Next.js project:

npx create-next-app@latest lucia-auth-app
cd lucia-auth-app

or, if you're using pnpm

pnpm dlx create-next-app@latest lucia-auth-app
cd lucia-auth-app

Follow all the steps to set up Next.js successfully and install the necessary dependencies.

npm install lucia zod argon2 prisma @prisma/client @lucia-auth/adapter-prisma react-hook-form @hookform/resolvers

or, if you're using pnpm

pnpm install lucia zod argon2 prisma @prisma/client @lucia-auth/adapter-prisma react-hook-form @hookform/resolvers

Setting Up Shadcn

Initialize shadcn:

npx shadcn@latest init

or, if you're using pnpm

pnpm dlx shadcn@latest init

You will be prompted with several questions to set up components.json. ShadCN will add CSS variables to your globals.css, update your tailwind.config.ts, and create a ui folder inside the components directory along with a lib folder containing utility functions.

Now start adding components to your project.

npx shadcn@latest add button

The button component can be found in your ui directory. You can modify it as needed; this article isn't focused on Shadcn, so you can find more details at ui.shadcn.com.

Setting Up Prisma and Database

Initialize Prisma and configure your database:

npx prisma init

Go to vercel.com, sign up with GitHub, and create your database, which uses Neon PostgreSQL under the hood. The process is straightforward: sign up, click "Add New," select "Store," and then click "Create Database." Once the database is created, click on "Prisma" and copy the following:

datasource db {
  provider = "postgresql"
  url = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

Open schema.prisma in your Prisma directory and replace its contents with what you copied. Then, open .env.local, copy everything, and create a new .env file in your root directory to paste the copied content.

POSTGRES_URL=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL_NO_SSL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER="default"
POSTGRES_HOST=""
POSTGRES_PASSWORD=""
POSTGRES_DATABASE=""

Define your database schema in prisma/shema.prisma:

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

model Session {
  id        String   @id
  userId    String
  expiresAt DateTime

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

  @@map("sessions")
}

To generate the Prisma client and apply your schema changes to the database, run the following commands in your terminal:

npx prisma generate    # This command generates the Prisma client, which allows you to interact with your database using Prisma.
npx prisma migrate dev --name init  # Replace "init" with a descriptive name for the migration

These commands will set up the database structure and generate the necessary client code for your application to interact with the database seamlessly

Create a new file named prisma.ts in your lib directory. This file will manage a single instance of PrismaClient that will be used throughout the entire application lifecycle. By implementing this approach, we optimize database connections, improve application performance, and ensure stability. This pattern is particularly beneficial during development, where hot reloading and module restarts can unintentionally create multiple PrismaClient instances, leading to increased resource usage and potential connection issues.

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

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

declare global {
  var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}

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

export default prisma;

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

Setting Up Lucia Auth

Create a new file lib/auth.ts to configure Lucia Auth:

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

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(databaseUserAttributes) {
    return {
      id: databaseUserAttributes.id,
      email: databaseUserAttributes.email,
      firstName: databaseUserAttributes.firstName,
      lastName: databaseUserAttributes.lastName
    };
  },
});

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

interface DatabaseUserAttributes {
  id: string;
  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);

    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;
  },
);

This code showcases how to integrate Lucia Auth with Prisma in Next.js 14, highlighting a secure and customizable approach to authentication. By using the Prisma adapter, Lucia manages user and session data seamlessly, with session cookie settings configured for security and getUserAttributes allowing precise control over which user data (like id, email, firstName, and lastName) is exposed to the application, enhancing privacy and security. Compared to Auth.js (NextAuth), Lucia provides more granular control over session management and user attributes, with a straightforward configuration that avoids unnecessary middleware and provides a simpler, more transparent handling of user data, making it ideal for projects needing tighter security and customizability.

Creating Login, Register and Logout Actions

First, let's set up our validation schemas for login and registration using Zod. Create a new file called validation in your lib directory and add the following code:

import { z } from "zod";

const requiredString = z.string().trim().min(1, "Required");

export const RegisterSchema = z.object({
  firstName: requiredString,
  lastName: requiredString,
  email: requiredString.email("Invalid email"),
  password: requiredString.min(8, "Must be at least 8 characters"),
});

export type RegisterType = z.infer<typeof RegisterSchema>;

export const LoginSchema = z.object({
  email: requiredString.email("Invalid email"),
  password: requiredString.min(8, "Must be at least 8 characters"),
});

export type LoginType = z.infer<typeof LoginSchema>;

Before creating our login, registration and logout actions, start by making a new file named actions in your lib directory. This file will contain all the actions for authentication. Now, let's add the register, login, and logout actions by pasting the following code into the actions file.

"use server";
import { lucia, validateRequest } from "@/lib/auth";
import prisma from "@/lib/prisma";
import {
  LoginSchema,
  LoginType,
  RegisterSchema,
  RegisterType,
} from "@/lib/validations";
import { hash, verify } from "argon2";
import { generateIdFromEntropySize } from "lucia";
import { isRedirectError } from "next/dist/client/components/redirect";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

//Register action
export async function registerAction(credentials: RegisterType) {
  try {
    const { firstName, lastName, email, password } =
      RegisterSchema.parse(credentials);

    const passwordHash = await hash(password, {
      memoryCost: 19456,
      timeCost: 2,
      parallelism: 1,
      hashLength: 32,
    });

    const userId = generateIdFromEntropySize(10);

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

    if (existingEmail) {
      return {
        error: "Email already taken",
      };
    }

    await prisma.user.create({
      data: {
        id: userId,
        firstName,
        lastName,
        email,
        password: passwordHash,
      },
    });

    const session = await lucia.createSession(userId, {});
    const sessionCookie = lucia.createSessionCookie(session.id);
    cookies().set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes,
    );

    return redirect("/");
  } catch (error) {
    if (isRedirectError(error)) throw error;
    console.error(error);
    return {
      error: "Something went wrong. Please try again",
    };
  }
}

//Loging action
export async function loginAction(credentials: LoginType) {
  try {
    const { email, password } = LoginSchema.parse(credentials);

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

    if (!existingUser || !existingUser.password) {
      return {
        error: "Incorrect username or password",
      };
    }

    const validPassword = await verify(existingUser.password, password);

    if (!validPassword) {
      return {
        error: "Incorrect username or password",
      };
    }

    const session = await lucia.createSession(existingUser.id, {});
    const sessionCookie = lucia.createSessionCookie(session.id);
    cookies().set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes,
    );

    return redirect("/");
  } catch (error) {
    if (isRedirectError(error)) throw error;
    console.error(error);
    return {
      error: "Something went wrong. Please try again.",
    };
  }
}

//Logout action
export async function logout() {
  const { session } = await validateRequest();

  if (!session) {
    throw new Error("Unauthorized");
  }

  await lucia.invalidateSession(session.id);

  const sessionCookie = lucia.createBlankSessionCookie();

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

  return redirect("/auth");
}

Creating the Register and Login Forms

Register Form

Create a directory named auth inside the app directory. Within the auth directory, create a page.tsx file, which will serve as the main page where the forms will be rendered as components. Next, create a file called register-form.tsx, which will contain the code provided below.

"use client";
import { Button } from "@/components/ui/button";
import {
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
  Form,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { registerAction } from "@/lib/actions";
import { RegisterSchema, RegisterType } from "@/lib/validations";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useState, useTransition } from "react";
import { SubmitHandler, useForm } from "react-hook-form";

const RegisterForm = () => {
  const [error, setError] = useState<string>();
  const [isPending, startTransition] = useTransition();

  const form = useForm<RegisterType>({
    resolver: zodResolver(RegisterSchema),
    defaultValues: {
      email: "",
      firstName: "",
      lastName: "",
      password: "",
    },
  });

  const onSubmit: SubmitHandler<RegisterType> = async (values) => {
    setError(undefined);

    startTransition(async () => {
      const { error } = await registerAction(values);
      if (error) setError(error);
    });
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
        {error ? <p className='text-center text-destructive'>{error}</p> : null}
        <div className='space-y-2'>
          <div className='flex gap-2 items-start md:flex-row flex-col'>
            <FormField
              control={form.control}
              name='firstName'
              render={({ field }) => (
                <FormItem>
                  <FormLabel>First name</FormLabel>
                  <FormControl>
                    <Input placeholder='John' {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name='lastName'
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Last name</FormLabel>
                  <FormControl>
                    <Input placeholder='Doe' {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <FormField
            control={form.control}
            name='email'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input placeholder='Email address' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='password'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input placeholder='Password' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
        <Button className='w-full'>Sign Up</Button>
      </form>
    </Form>
  );
};

export default RegisterForm;

Login Form

Create a new file named login-form.tsx, and add the following code inside it.

"use client";
import React, { useState, useTransition } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, LoginType } from "@/lib/validations";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { loginAction } from "@/lib/actions";
const LoginForm = () => {
  const [error, setError] = useState<string>();
  const [isPending, startTransition] = useTransition();

  const form = useForm<LoginType>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const onSubmit: SubmitHandler<LoginType> = async (values) => {
    setError(undefined);

    startTransition(async () => {
      const { error } = await loginAction(values);
      if (error) setError(error);
    });
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
        {error ? <p className='text-center text-destructive'>{error}</p> : null}
        <div className='space-y-2'>
          <FormField
            control={form.control}
            name='email'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input placeholder='Email address' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='password'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input placeholder='Password' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
        <Button className='w-full'>Sign In</Button>
      </form>
    </Form>
  );
};

export default LoginForm;

Auth Page

Copy code below into your page.tsx inside your auth directory:

import LoginForm from "@/app/auth/login-form";
import RegisterForm from "@/app/auth/register-form";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import React from "react";

const Auth = () => {
  return (
    <section className='mx-auto min-h-screen flex justify-center items-center container'>
      <Tabs defaultValue='login' className='w-[546px]'>
        <TabsList className='grid w-full grid-cols-2 h-11'>
          <TabsTrigger value='login'>Login</TabsTrigger>
          <TabsTrigger value='register'>Register</TabsTrigger>
        </TabsList>
        <TabsContent value='login'>
          <Card>
            <CardHeader>
              <CardTitle>Login</CardTitle>
              <CardDescription>
                Please log in to access your account. Enter your email and
                password to continue. If you don&apos;t have an account, you can
                register to get started
              </CardDescription>
            </CardHeader>
            <CardContent>
              <LoginForm />
            </CardContent>
          </Card>
        </TabsContent>
        <TabsContent value='register'>
          <Card>
            <CardHeader>
              <CardTitle>Register</CardTitle>
              <CardDescription>
                Create a new account by filling in your details below. Enter
                your name, email, and password to get started. Already have an
                account? Log in instead.
              </CardDescription>
            </CardHeader>
            <CardContent>
              <RegisterForm />
            </CardContent>
          </Card>
        </TabsContent>
      </Tabs>
    </section>
  );
};

export default Auth;

Managing Sessions for Server and Client Components

Server Components

For server components, it's simple: make the component asynchronous and retrieve the session by calling the validateRequest function from lib/auth.ts, which returns the user's session if they are logged in.

Client Components

For client components, we can't call validateRequest directly because it runs on the server, while client components run in the browser. To share the session with client components, we can use the React Context API to create a SessionProvider that wraps the components. This allows all child components to access the session data. Additionally, we create a useSession hook, so child components can access the session without needing to pass it down through props (avoiding prop drilling). Here's the code for the SessionProvider and useSession hook.

"use client";

import { Session, User } from "lucia";
import React from "react";

interface SessionContext {
  user: User;
  session: Session;
}

const SessionContext = React.createContext<SessionContext | null>(null);

export default function SessionProvider({
  children,
  context,
}: React.PropsWithChildren<{ context: SessionContext }>) {
  return (
    <SessionContext.Provider value={context}>
      {children}
    </SessionContext.Provider>
  );
}

export function useSession() {
  const context = React.useContext(SessionContext);

  if (!context)
    throw new Error("useSession must be used within a SessionProvider");

  return context;
}

Let’s walk through how to use the SessionProvider and useSession in our app. First, create a route group called main in your app directory and add a layout.tsx file, which will be a server component. In this layout, we’ll call the validateRequest function to get the session and pass it to the SessionProvider, which will wrap around the children. In Next.js, layout.tsx defines shared components and wraps page content, making it perfect for managing global state like session data.

import { validateRequest } from "@/lib/auth";
import SessionProvider from "@/providers/session-provider";
import { redirect } from "next/navigation";

export default async function MainLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const session = await validateRequest();

  if (!session.user) redirect("/auth");

  return <SessionProvider context={session}>{children}</SessionProvider>;
}

Next, let's create a new page.tsx file inside the main route group. First, delete the existing page.tsx in the app directory to ensure our new file becomes the index page. Be sure to add "use client" at the top to mark it as a client component, enabling it to access the session using the useSession hook.

"use client";
import { Button } from "@/components/ui/button";
import { logoutAction } from "@/lib/actions";
import { useSession } from "@/providers/session-provider";

export default function Home() {
  const session = useSession();
  return (
    <div className='flex justify-center items-center min-h-screen w-full'>
      <div className='container flex items-center justify-center'>
        <div className='flex flex-col gap-2'>
          <span className='text-3xl'>
            {session.user.firstName} {session.user.lastName}
          </span>
          <span className='text-2xl'>{session.user.email}</span>
          <Button
            onClick={() => {
              logoutAction();
            }}
          >
            Logout
          </Button>
        </div>
      </div>
    </div>
  );
}

Conclusion

In this comprehensive guide, we've walked through the process of implementing robust email and password authentication in a Next.js application using Lucia Auth. By leveraging Lucia's flexible and straightforward approach, we've created a secure authentication system that offers several advantages:

  1. Simplified Session Management: Lucia Auth streamlines the complexities of handling user sessions, providing a secure and efficient way to manage authentication states.

  2. Database Flexibility: Through its integration with Prisma, Lucia Auth offers seamless compatibility with various databases, allowing developers to choose the best storage solution for their project.

  3. Customizable Authentication Flow: We've demonstrated how to create custom register and login forms, showcasing Lucia's adaptability to specific project requirements.

  4. Server-Side Security: By focusing on server-side operations, Lucia Auth enhances the overall security of the authentication process, reducing client-side vulnerabilities.

  5. Efficient State Management: The implementation of SessionProvider and useSession hook illustrates an elegant solution for managing authentication state across both server and client components in Next.js.

This approach to authentication not only secures your application but also provides a foundation that can be easily extended to include additional features such as password reset functionality, email verification, or integration with OAuth providers for social logins.

As web applications continue to evolve, the importance of robust, flexible, and user-friendly authentication systems cannot be overstated. Lucia Auth, with its straightforward API and focus on developer control, positions itself as a valuable tool in the modern web developer's toolkit.

By following this guide, you've not only implemented a secure authentication system but also gained insights into best practices for managing user data, sessions, and security in Next.js applications. As you continue to build and scale your projects, the principles and techniques covered here will serve as a solid foundation for creating secure, user-centric web applications.

For those who want to explore the complete implementation or need additional reference, you can find the full project code on GitHub:

https://github.com/delinuxist/lucia-auth-blog

This repository contains all the code we've discussed in this article, providing a practical resource for your own implementations and further learning.

0
Subscribe to my newsletter

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

Written by

Emmanuel Obeng Twene
Emmanuel Obeng Twene