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'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:
Simplified Session Management: Lucia Auth streamlines the complexities of handling user sessions, providing a secure and efficient way to manage authentication states.
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.
Customizable Authentication Flow: We've demonstrated how to create custom register and login forms, showcasing Lucia's adaptability to specific project requirements.
Server-Side Security: By focusing on server-side operations, Lucia Auth enhances the overall security of the authentication process, reducing client-side vulnerabilities.
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.
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