If you're building a project with Next.js, chances are that you'd like to have some kind of authentication at some point. Giving your users the capability of simply logging in to your app opens many doors; establishing a user base, restricting access to certain areas of your app, and so on.
There are many ways of doing that - one of them is using NextAuth (or just Auth.js if you prefer the new name). For my projects, I've mostly used NextAuth and have been pretty happy with it so far. But especially since version 5 lingers around the corner, the docs can get a bit confusing sometimes - in this series, I'd like to show you how to add it to your project, what caveats to expect, how to overcome them, and how to generally make use of a powerful authentication library like NextAuth.
Series content
We're going to cover common use cases for NextAuth, including:
- Part 1: Setup & Logging in
How to properly add NextAuth to Next.js and configure different providers, including OAuth, login via email and login via credentials - Part 2: Basic- and role-based authorization [UNRELEASED]
How to prevent users to access certain content on your app, including adding roles to add even more control - Part 3: Deeper dive: customization, session strategies, callbacks and events [UNRELEASED]
Exploring further configurations like adding callbacks to register your users to any other app (e.g. Stripe) whenever they sign up to your app, customizing predefined components (like the login form), and alike - Part 4: Profiles [UNRELEASED]
How to add user profiles to your app, so that your users can update their profile.
I'll cover these topics to the extent I have used and found useful so far.
Tech Stack
This guide was initially written using the following versions:
- Next.js 14 with app directory
- NextAuth 4
- TypeScript 5
- For our ORM and database we're going to cover Prisma 5 with SQLite, and briefly talk about a different approach with Drizzle 0.31 and PostgreSQL which is more serverless-friendly.
Check the post footer to see when this guide was last updated. The link to the source code is available at the end.
Prerequisites
If you haven't already, let's start by creating a new Next.js application:
npx create-next-app@latest
I prefer to use the src
directory and TailwindCSS - but the only required option for this guide is that you use TypeScript.
Setting up our database
While it's possible to integrate NextAuth without using a database with OAuth providers and the jwt
strategy, some other providers - like the ones used for this guide (emails and credentials) - require a database. So let's get our database working first!
There are many options and combinations to choose from when it comes to database setups.
For the sake of simplicity, we'll just use Prisma with SQLite. As a bonus, we'll briefly cover how to use Prisma with PostgreSQL which is also available as a dedicated repository branch.
Prisma with SQLite
First, let's install Prisma and its NextAuth adapter:
npm install @prisma/client @auth/prisma-adapter
npm install prisma --save-dev
After that, we can initialize our schema with:
npx prisma init --datasource-provider sqlite
This command will create a .env
file which includes our DATABASE_URL
(file:./dev.db
by default). In order to follow good practices, duplicate this file and rename it to .env.local
- Next.js will automatically prefer this file to the .env
file.
.env
file and always put it in .env.local
which is ignored from versioning! The .env
(which is also a git versioned file) usually just provides a list of available environment variables without any values or just placeholders.The prisma init
command also created a Prisma schema file at prisma/schema.prisma
, which we now need to adjust our schema to include the models required for NextAuth:
In order to follow Prisma best practices (so that we don't run into some weird "There are already 10 instances of Prisma Client actively running." warnings), we can define a single instance of Prisma that we're going to use in our applications:
Last but not least we need to actually create our schema in our database. Run the following command:
This will create a dev.db
file in our prisma
directory which is our SQLite database with the defined schema.
Bonus: Drizzle with PostgreSQL
One approach I'd also like to show is how to set up using Drizzle with PostgreSQL. Considering SQLite doesn't work on serverless environments, this might be a more suitable solution for your app. Of course, you can also interchange these technologies and use Prisma with PostgreSQL or Drizzle with SQLite.
For the rest of the guide, we'll continue under the assumption you're using Prisma and SQLite - but the guide repository does contain the entire code for doing the exact same thing in Drizzle and PostgreSQL.
For using Drizzle with PostgreSQL, we need to install our dependencies first:
npm install drizzle-orm @auth/drizzle-adapter postgres dotenv
npm install drizzle-kit --save-dev
Next, we need to add an environment variable that is used by Drizzle to connect to our database.
In order to quickly bootstrap a local PostgreSQL database we can make use of Docker. This one-liner should be sufficient:
docker run -d \
--name pgdb \
-e POSTGRES_DB=app \
-e POSTGRES_USER=user \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 \
postgres
Next, we need to provide a drizzle.config.ts
that has to be put in the root of your project:
import { defineConfig } from "drizzle-kit";
require("dotenv").config();
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
And our schema:
import {
boolean,
integer,
pgTable,
primaryKey,
text,
timestamp,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "next-auth/adapters";
export const users = pgTable("user", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email"),
password: text("password"),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
account => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
}),
);
// This table can be deleted if you're not using the `database` session strategy,
// but be careful: using an adapter automatically sets the session strategy to `database`!
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
verificationToken => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
}),
);
export const authenticators = pgTable(
"authenticator",
{
credentialID: text("credentialID").notNull().unique(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerAccountId: text("providerAccountId").notNull(),
credentialPublicKey: text("credentialPublicKey").notNull(),
counter: integer("counter").notNull(),
credentialDeviceType: text("credentialDeviceType").notNull(),
credentialBackedUp: boolean("credentialBackedUp").notNull(),
transports: text("transports"),
},
authenticator => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
}),
);
Just like with Prisma, we should make a global instance of our drizzle db client:
import "dotenv/config";
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../schema";
declare global {
var db: PostgresJsDatabase<typeof schema> | undefined;
}
let db: PostgresJsDatabase<typeof schema>;
export const client = postgres(`${process.env.DATABASE_URL}`);
if (process.env.NODE_ENV === "production") {
db = drizzle(client, { schema });
} else {
if (!global.db) {
global.db = drizzle(client, { schema });
}
db = global.db;
}
export { db };
Lastly, push our schema to our database:
./node_modules/.bin/drizzle-kit push
With this, we can now use Drizzle and PostgreSQL for NextAuth. Check out the repository branch for the full source code. As mentioned before, for the rest of the guide we'll assume you've chosen Prisma and SQLite.
Setting up NextAuth
With all of this being done, we're now ready to add NextAuth to our application. First, let's install the dependency. Since we're using Prisma we can also already install the proper adapter for that:
Auth page
For convenience, we can refactor our home page to show auth-related information and provide some buttons, so that we don't have to manually navigate between pages:
import { authOptions } from "@/lib/auth";
import { getServerSession } from "next-auth";
import Link from "next/link";
export default async function Home() {
const session = await getServerSession(authOptions);
return (
<div className="mx-auto border w-96 rounded p-6 my-6 border-gray-700">
<div>Status: {session ? "authenticated" : "unauthenticated"}</div>
{session ? (
<>
<pre className="overflow-y-auto">
{JSON.stringify(session, null, 2)}
</pre>
<Link
href="/api/auth/signout"
className="mt-3 inline-block rounded bg-blue-600 font-semibold px-2 py-1"
>
Logout
</Link>
</>
) : (
<>
<Link
href="/api/auth/signin"
className="mt-3 inline-block rounded bg-blue-600 font-semibold px-2 py-1"
>
Login
</Link>
</>
)}
</div>
);
}
Which will look something like this:
Basic configuration
The next thing we want to take care of is configuring NextAuth. My preferred way to do that is to create a lib/auth.ts
file that holds our configuration and is used everywhere needed:
We additionally need some environment variables that NextAuth is going to consume inside our .env.local
file:
NEXTAUTH_SECRET
is simply a random string that's used for encryption of our JWT token; an easy way to generate this token is the following command:
openssl rand -base64 32
NEXTAUTH_URL
refers to our URL - which is simply localhost:3000
for local development. If your site is deployed anywhere and you do have a domain, make sure to adjust this value properly.
Route handler
Next, we have to create a route handler that NextAuth is going to use. The recommended way of doing so is:
import { authOptions } from "@/lib/auth";
import NextAuth from "next-auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
This causes the GET
and POST
route at /api/auth/*
to be handled by NextAuth. For example, if you navigate to /api/auth/signin
the sign in handler of NextAuth will take care of everything and render the default login page.
Logging in
There are a bunch of different methods on how to log in - let's look at the most popular ones and how to implement them in NextAuth.
Login via email
In recent years login via "magic links" has become quite popular, especially since you get rid of the unpleasant experience of remembering (and storing) passwords. Adding this kind of login in NextAuth is rather easy:
The first thing we need to do is to install nodemailer which is used by NextAuth for sending emails:
npm i nodemailer
After that, we can add our first provider:
We now need some additional environment variables in our .env
file:
EMAIL_SERVER=smtp://localhost:1025
EMAIL_FROM=noreply@nehalist.io
For rapid local development, I always like to use Docker. Check out my guide for using Docker for web development or use this command to quickly bootstrap a mail server on your local machine:
docker run -d \
--restart unless-stopped \
--name=mailpit \
-p 8025:8025 \
-p 1025:1025 \
axllent/mailpit
This command will start an SMTP server on port 1025
and a very handy UI at localhost:8025
.
If we now head to localhost:3000/api/auth/signin
we should see a button to login via email:
After you've entered any email, check out localhost:8025
to receive your magic link:
Open the email, click on the "Sign in" link and you should be redirected to your app - where you now are logged in!
Login via OAuth
If you want to give your users the option to log in via a third-party provider, like Google or GitHub, NextAuth makes it fairly easy to set up that. For this guide we'll take a closer look at how to set up login via GitHub - but the principle applies to pretty much all providers.
The provider itself is quickly added by using the GitHubProvider
from NextAuth for our auth.ts
configuration file:
import prisma from "@/lib/db";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { AuthOptions } from "next-auth";
import { Adapter } from "next-auth/adapters";
import GitHubProvider from "next-auth/providers/github";
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, // this cast is required to prevent type errors, see https://github.com/nextauthjs/next-auth/issues/6106
providers: [
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_SECRET!,
})
],
debug: process.env.NODE_ENV === "development",
};
As with most login providers we require additional environment variables; GITHUB_CLIENT_ID
and GITHUB_SECRET
this time.
To get these values head over to the GitHub Developer Settings OAuth Apps. Click on "New OAuth App" and fill out your form like this:
The important part here is the "Authorization callback URL" which equals to http://localhost:3000/api/auth/callback/github
, which should be adjusted accordingly if you've deployed your app with a proper domain. The /api/auth/callback/github
part stays the same, regardless the domain.
After clicking on "Register application" our app should be successfully registered and we should see the details of it:
Since we need a client ID and a client secret, click on "Generate a new client secret". GitHub will create a new secret for you which is only visible once, so be sure to copy it;
Copy your client ID and client secret and put them into your .env.local
file:
GITHUB_CLIENT_ID=<your client id>
GITHUB_SECRET=<your client secret>
Heading back to our login at localhost:3000/api/auth/signin
we should now have the option to log in via GitHub:
Clicking on that button will redirect you to GitHub where you're asked for permission to log in to your newly created app - if you authorize your app you should be redirected to your app and be logged in!
Login via credentials
If you like a more old-fashioned login method you can also implement login via username and passwods. Unfortunately, NextAuth doesn't want you to use credentials for various reasons hence making it really uncomfortable to set up - but we can still do it.
Let's alter our Prisma schema so that users do have passwords in our database. Add an optional password
field to our User
model:
Don't forget to update your database accordingly with:
npx prisma db push
With that being done, we can finally come to the fun part: adding our credentials provider. Unlike our previous providers this is unfortunately more work than just some configurations, but let's go through it step by step:
First, let's add our provider:
import prisma from "@/lib/db";
import { PrismaClient } from "@prisma/client";
import { AuthOptions } from "next-auth";
import { Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, // this cast is required to prevent type errors, see https://github.com/nextauthjs/next-auth/issues/6106
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
// we'll come to this in a moment
}
})
],
debug: process.env.NODE_ENV === "development",
};
For this guide, we'll keep the authorize
the method as simple as possible: create a user if it doesn't exist, compare the hashed and salted password, and return a user object if everything succeeded. In the real world you'd likely have a dedicated signup page and not just create the user if it doesn't exist - but that's something for another day.
For hashing and salting passwords we're going to use bcrypt - let's install that really quick:
npm i bcrypt
Let's implement our authorize
method:
import prisma from "@/lib/db";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { compare, genSalt, hash } from "bcrypt";
import { NextAuthOptions } from "next-auth";
import { Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";
// A simple helper to hash and salt passwords
async function hashAndSaltPassword(password: string, saltRounds = 10) {
const salt = await genSalt(saltRounds);
return hash(password, salt);
}
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, // this cast is required to prevent type errors, see https://github.com/nextauthjs/next-auth/issues/6106
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Return null if no credentials were given
if (!credentials) {
return null;
}
// Try to find our user by the entered username
let user = await prisma.user.findFirst({
where: {
name: credentials.username,
},
});
// If the user has not been found, create it using the entered credentials
if (!user) {
user = await prisma.user.create({
data: {
name: credentials.username,
password: await hashAndSaltPassword(credentials.password),
},
});
}
// If the user has no password (for example because it's an OAuth user), return null
if (!user.password) {
return null;
}
// Compare our entered password with the user password from the database
const comparison = await compare(credentials.password, user.password);
if (comparison) {
// If the comparison is true we can finally return a user object
return {
id: user.id,
name: user.name,
email: user.email,
};
}
// Return null if the comparison didn't succeed
return null;
},
}),
],
debug: process.env.NODE_ENV === "development",
};
If at this point you're using the jwt
session strategy, you should be able to log in. Otherwise, if you're using the database
strategy (which is automatically used if you provide an adapter to Prisma!), you'll see that we still can't log in, since we don't get a session from NextAuth. You can still manually enforce the jwt
strategy by specifying it explicitly - but if you want to use the database strategy, we need to take care of our session ourselves.
To do so, we need to use the signIn
callback and create a session for our user manually:
import prisma from "@/lib/db";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { compare, genSalt, hash } from "bcrypt";
import { NextAuthOptions } from "next-auth";
import { Adapter } from "next-auth/adapters";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
async function hashAndSaltPassword(password: string, saltRounds = 10) {
const salt = await genSalt(saltRounds);
return hash(password, salt);
}
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, // this cast is required to prevent type errors, see https://github.com/nextauthjs/next-auth/issues/6106
providers: [
/* ... */
],
callbacks: {
async signIn({ user, account }) {
// We only want to handle this for credentials provider
if (account?.provider !== "credentials") {
return true;
}
// Our session/cookie settings
const tokenName =
process.env.NODE_ENV === "development"
? "next-auth.session-token"
: "__Secure-next-auth.session-token";
const expireAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const token = require("crypto").randomBytes(32).toString("hex");
// Create a session in our database
await prisma.session.create({
data: {
sessionToken: token,
userId: user.id,
expires: expireAt,
},
});
// Set our cookie
cookies().set(tokenName, token, {
expires: expireAt,
secure: process.env.NODE_ENV !== "development",
sameSite: "strict",
path: "/",
});
// Return to "/" after sign in
return "/";
},
},
debug: process.env.NODE_ENV === "development",
};
Now we're finally able to login via credentials, regardless of the strategy:
After entering whatever credentials we'll be redirected to our home page and be logged in!
Source code
The source code for this project is available on GitHub. If you're interested in the Drizzle/PostgreSQL implementation, check out the "drizzle-pg" branch. Future changes within this series can also be found in this repository.
Conclusion & what's next
Hopefully, I could provide some help on how to add authentication to your application. Especially with NextAuth 5 around the corner, the official docs can become a bit confusing - and since NextAuth 5 is still beta, it's still completely reasonable to invest in NextAuth 4.
I have to admit publishing this post took way longer than I anticipated - mostly due to things like being sick, switching workspaces, and rewriting major parts of this guide multiple times because of many reasons that can be boiled down to "well planned is half done" - and I didn't plan very well at first 🥲
Authentication is a huge topic, even with libraries like NextAuth - which is why I ultimately went with a multi-part series approach this time. In the next part (which hopefully will be done a lot faster than this one) we'll start to cover more advanced topics like authorization and how to (dis-)allow users access to certain areas of our app, based on different criteria like their role, their status and alike.
While the table of contents of this series is outlined at the top, it's also subject to change. Feel free to leave a comment or write me a message if you think something is missing, on what topics you'd like to have additional guidance or if for any other questions or suggestions!