Sign in with username and password in Next.js App Router

Before starting, make sure you’ve setup Lucia and your database.

This guide will cover how to implement a simple username and password authentication using Lucia in Next.js App Router. It will have 3 parts:

  • A sign up page
  • A sign in page
  • A profile page with a logout button

Clone project#

You can get started immediately by cloning the Next.js example from the repository.

npx degit lucia-auth/examples/nextjs-app/username-and-password <directory_name>

Alternatively, you can open it in StackBlitz.

Update your database#

Add a username column to your table. It should be a string (TEXT, VARCHAR etc) type that’s unique.

Make sure you update Lucia.DatabaseUserAttributes whenever you add any new columns to the user table.

// app.d.ts

/// <reference types="lucia" />
declare namespace Lucia {
	type Auth = import("./lucia.js").Auth;
	type DatabaseUserAttributes = {
		username: string;
	};
	type DatabaseSessionAttributes = {};
}

Configure Lucia#

Set sessionCookie.expires to false since we can’t update the session cookie when validating them.

// auth/lucia.ts
import { lucia } from "lucia";
import { nextjs_future } from "lucia/middleware";

export const auth = lucia({
	adapter: ADAPTER,
	env: process.env.NODE_ENV === "development" ? "DEV" : "PROD",
	middleware: nextjs_future(),

	sessionCookie: {
		expires: false
	}
});

export type Auth = typeof auth;

We’ll also expose the user’s username to the User object by defining getUserAttributes.

// auth/lucia.ts
import { lucia } from "lucia";
import { nextjs_future } from "lucia/middleware";

export const auth = lucia({
	adapter: ADAPTER,
	env: process.env.NODE_ENV === "development" ? "DEV" : "PROD",
	middleware: nextjs_future(),
	sessionCookie: {
		expires: false
	},

	getUserAttributes: (data) => {
		return {
			username: data.username
		};
	}
});

export type Auth = typeof auth;

Form component#

Since the form will require client side JS, we will extract it into its own client component. We need to manually handle redirect responses as the default behavior is to make another request to the redirect location. We’re going to use refresh() to reload the page (and redirect the user in the server) since we want to re-render the entire page, including layout.tsx.

// components/form.tsx
"use client";

import { useRouter } from "next/navigation";

const Form = ({
	children,
	action
}: {
	children: React.ReactNode;
	action: string;
}) => {
	const router = useRouter();
	return (
		<form
			action={action}
			method="post"
			onSubmit={async (e) => {
				e.preventDefault();
				const formData = new FormData(e.currentTarget);
				const response = await fetch(action, {
					method: "POST",
					body: formData,
					redirect: "manual"
				});

				if (response.status === 0) {
					// redirected
					// when using `redirect: "manual"`, response status 0 is returned
					return router.refresh();
				}
			}}
		>
			{children}
		</form>
	);
};

export default Form;

Sign up page#

Create app/signup/page.tsx and add a form with inputs for username and password. The form should make a POST request to /api/signup.

// app/signup/page.tsx
import Form from "@/components/form";
import Link from "next/link";

const Page = async () => {
	return (
		<>
			<h1>Sign up</h1>
			<Form action="/api/signup">
				<label htmlFor="username">Username</label>
				<input name="username" id="username" />
				<br />
				<label htmlFor="password">Password</label>
				<input type="password" name="password" id="password" />
				<br />
				<input type="submit" />
			</Form>
			<Link href="/login">Sign in</Link>
		</>
	);
};

export default Page;

Create users#

Create app/api/signup/route.ts and handle POST requests.

Users can be created with Auth.createUser(). This will create a new user, and if key is defined, a new key. The key here defines the connection between the user and the provided unique username (providerUserId) when using the username & password authentication method (providerId). We’ll also store the password in the key. This key will be used get the user and validate the password when logging them in. The type for attributes property is Lucia.DatabaseUserAttributes, which we added username to previously.

After successfully creating a user, we’ll create a new session with Auth.createSession() and store it as a cookie with AuthRequest.setSession(). AuthRequest can be created by calling Auth.handleRequest() with the request method, cookies(), and `headers()..

// app/api/signup/route.ts
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { NextResponse } from "next/server";

import type { NextRequest } from "next/server";

export const POST = async (request: NextRequest) => {
	const formData = await request.formData();
	const username = formData.get("username");
	const password = formData.get("password");
	// basic check
	if (
		typeof username !== "string" ||
		username.length < 4 ||
		username.length > 31
	) {
		return NextResponse.json(
			{
				error: "Invalid username"
			},
			{
				status: 400
			}
		);
	}
	if (
		typeof password !== "string" ||
		password.length < 6 ||
		password.length > 255
	) {
		return NextResponse.json(
			{
				error: "Invalid password"
			},
			{
				status: 400
			}
		);
	}
	try {
		const user = await auth.createUser({
			key: {
				providerId: "username", // auth method
				providerUserId: username.toLowerCase(), // unique id when using "username" auth method
				password // hashed by Lucia
			},
			attributes: {
				username
			}
		});
		const session = await auth.createSession({
			userId: user.userId,
			attributes: {}
		});
		const authRequest = auth.handleRequest(request.method, context);
		authRequest.setSession(session);
		return new Response(null, {
			status: 302,
			headers: {
				Location: "/" // redirect to profile page
			}
		});
	} catch (e) {
		// this part depends on the database you're using
		// check for unique constraint error in user table
		if (
			e instanceof SomeDatabaseError &&
			e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR
		) {
			return NextResponse.json(
				{
					error: "Username already taken"
				},
				{
					status: 400
				}
			);
		}

		return NextResponse.json(
			{
				error: "An unknown error occurred"
			},
			{
				status: 500
			}
		);
	}
};

Case sensitivity#

Depending on your database, user123 and USER123 may be treated as different strings. To avoid 2 users having the same username with different cases, we are going to make the username lowercase before creating a key. This is crucial when setting a user-provided input as a provider user id of a key.

On the other hand, making the username stored as a user attribute lowercase is optional. However, if you need to query users using usernames (e.g. url /user/user123), it may be beneficial to require the username to be lowercase, store 2 usernames (lowercase and normal), or set the database to ignore casing when compare strings (e.g. using LOWER() in SQL).

const user = await auth.createUser({
	key: {
		providerId: "username", // auth method
		providerUserId: username.toLowerCase(), // unique id when using "username" auth method
		password // hashed by Lucia
	},
	attributes: {
		username
	}
});

Error handling#

Lucia throws 2 types of errors: LuciaError and database errors from the database driver or ORM you’re using. Most database related errors, such as connection failure, duplicate values, and foreign key constraint errors, are thrown as is. These need to be handled as if you were using just the driver/ORM.

if (
	e instanceof SomeDatabaseError &&
	e.message === USER_TABLE_UNIQUE_CONSTRAINT_ERROR
) {
	// username already taken
}

Redirect authenticated users#

Authenticated users should be redirected to the profile page whenever they try to access the sign up page. You can validate requests by creating by calling AuthRequest.validate(). This method returns a Session if the user is authenticated or null if not.

For Auth.handleRequest(), pass "GET" as the request method.

// app/signup/page.tsx
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { redirect } from "next/navigation";

import Form from "@/components/form";

const Page = async () => {
	const authRequest = auth.handleRequest("GET", context);
	const session = await authRequest.validate();
	if (session) redirect("/");
	// ...
};

export default Page;

Sign in page#

Create app/login/page.tsx and also add a form with inputs for username and password. The form should make a POST request to /api/login.

// app/login/page.tsx
import Form from "@/components/form";
import Link from "next/link";

const Page = async () => {
	return (
		<>
			<h1>Sign in</h1>
			<Form action="/api/login">
				<label htmlFor="username">Username</label>
				<input name="username" id="username" />
				<br />
				<label htmlFor="password">Password</label>
				<input type="password" name="password" id="password" />
				<br />
				<input type="submit" />
			</Form>
			<Link href="/signup">Create an account</Link>
		</>
	);
};

export default Page;

Authenticate users#

Create app/api/login/route.ts and handle POST requests.

The key we created for the user allows us to get the user via their username, and validate their password. This can be done with Auth.useKey(). If the username and password is correct, we’ll create a new session just like we did before. If not, Lucia will throw an error. Make sure to make the username lowercase before calling useKey().

// app/api/login/route.ts
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { NextResponse } from "next/server";
import { LuciaError } from "lucia";

import type { NextRequest } from "next/server";

export const POST = async (request: NextRequest) => {
	const formData = await request.formData();
	const username = formData.get("username");
	const password = formData.get("password");
	// basic check
	if (
		typeof username !== "string" ||
		username.length < 1 ||
		username.length > 31
	) {
		return NextResponse.json(
			{
				error: "Invalid username"
			},
			{
				status: 400
			}
		);
	}
	if (
		typeof password !== "string" ||
		password.length < 1 ||
		password.length > 255
	) {
		return NextResponse.json(
			{
				error: "Invalid password"
			},
			{
				status: 400
			}
		);
	}
	try {
		// find user by key
		// and validate password
		const key = await auth.useKey("username", username.toLowerCase(), password);
		const session = await auth.createSession({
			userId: key.userId,
			attributes: {}
		});
		const authRequest = auth.handleRequest(request.method, context);
		authRequest.setSession(session);
		return new Response(null, {
			status: 302,
			headers: {
				Location: "/" // redirect to profile page
			}
		});
	} catch (e) {
		if (
			e instanceof LuciaError &&
			(e.message === "AUTH_INVALID_KEY_ID" ||
				e.message === "AUTH_INVALID_PASSWORD")
		) {
			// user does not exist or invalid password
			return NextResponse.json(
				{
					error: "Incorrect username or password"
				},
				{
					status: 400
				}
			);
		}
		return NextResponse.json(
			{
				error: "An unknown error occurred"
			},
			{
				status: 500
			}
		);
	}
};

Redirect authenticated users#

As we did in the sign up page, redirect authenticated users to the profile page.

// app/login/page.tsx
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { redirect } from "next/navigation";

import Form from "@/components/form";
import Link from "next/link";

const Page = async () => {
	const authRequest = auth.handleRequest("GET", context);
	const session = await authRequest.validate();
	if (session) redirect("/");
	// ...
};

export default Page;

Profile page#

Create app/page.tsx. This page will show some basic user info and include a logout button.

Unauthenticated users should be redirected to the login page. The user object is available in Session.user, and you’ll see that User.username exists because we defined it in first step with getUserAttributes() configuration.

// app/page.tsx
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { redirect } from "next/navigation";

import Form from "@/components/form";

const Page = async () => {
	const authRequest = auth.handleRequest("GET", context);
	const session = await authRequest.validate();
	if (!session) redirect("/login");
	return (
		<>
			<h1>Profile</h1>
			<p>User id: {session.user.userId}</p>
			<p>Username: {session.user.username}</p>
			<Form action="/api/logout">
				<input type="submit" value="Sign out" />
			</Form>
		</>
	);
};

export default Page;

Sign out users#

Create app/api/logout/route.ts and handle POST requests.

When logging out users, it’s critical that you invalidate the user’s session. This can be achieved with Auth.invalidateSession(). You can delete the session cookie by overriding the existing one with a blank cookie that expires immediately. This can be created by passing null to AuthRequest.setSession().

// app/api/logout/route.ts
import { auth } from "@/auth/lucia";
import * as context from "next/headers";

import type { NextRequest } from "next/server";

export const POST = async (request: NextRequest) => {
	const authRequest = auth.handleRequest(request.method, context);
	// check if user is authenticated
	const session = await authRequest.validate();
	if (!session) {
		return new Response(null, {
			status: 401
		});
	}
	// make sure to invalidate the current session!
	await auth.invalidateSession(session.sessionId);
	// delete session cookie
	authRequest.setSession(null);
	return new Response(null, {
		status: 302,
		headers: {
			Location: "/login" // redirect to login page
		}
	});
};

Additional notes#

For getting the current user in page.tsx and layout.tsx, we recommend wrapping AuthRequest.validate() in cache(), which is provided by React. This should not be used inside route.tsx as Lucia will assume the request is a GET request.

export const getPageSession = cache(() => {
	const authRequest = auth.handleRequest("GET", context);
	return authRequest.validate();
});

This allows you share the session across pages and layouts, making it possible to validate the request in multiple layouts and page files without making unnecessary database calls.

const Page = async () => {
	const session = await getPageSession();
};