After all the preparation, it is finally time for us to implement our authentication system. We'll start with a magic link system in order to get familiarized with all the new concepts, and later we'll discuss other authentication systems as well.
Think about how a magic link authentication workflow looks like:
- The user enters their email address on the website. Instead of asking for a password, the system sends them a special link (the "magic link") via email.
- The user opens their email and clicks the magic link. This link is unique and can only be used once, and it usually expires after a short time for security.
- When the user clicks the link, Auth.js recognizes it as valid and logs them in automatically, no password required.
We need two critical components for this magic link system to work, a database and a email provider. The database stores information, including the user information, token, expiry time, and so on. The email provider delivers the magic link to the user's inbox.
Setting up the database
We'll start by setting up the database, and again, we are going to use Prisma.js and SQLite as an example.
Run the following command to install Prisma:
1npm install prisma --save-dev
And also the Prisma client, which is a JavaScript library that allows us to interact with Prisma in our app.
1npm install @prisma/client
Finally, initialize Prisma.js to use SQLite:
1npx prisma init --datasource-provider sqlite
1✔ Your Prisma schema was created at prisma/schema.prisma
2 You can now open it in your favorite editor.
3
4. . .
A prisma
folder will be generated under the project root directory, where you'll find the database schema file, schema.prisma
.
Create a SQLite database under the same prisma
directory.
1.
2├── README.md
3├── eslint.config.mjs
4├── jsconfig.json
5├── next.config.mjs
6├── package-lock.json
7├── package.json
8├── postcss.config.mjs
9├── prisma
10│ ├── database.sqlite <===== SQLite database
11│ └── schema.prisma <===== Database schema
12├── public
13├── src
14│ ├── app
15│ ├── components
16│ └── libs
17└── tailwind.config.mjs
Add a DATABASE_URL
environmental variable, which should point to the SQLite database we just created. Note that the path is relative to the schema.prisma
file.
.env
1AUTH_SECRET="pr9JjImhp+lFbaeFx0MthkSO5idL0tjtavYcFDBE6h0="
2DATABASE_URL="file:./database.sqlite"
In the schema file, Prisma will use the DATABASE_URL to locate the SQLite database.
schema.prisma
1// This is your Prisma schema file,
2// learn more about it in the docs: https://pris.ly/d/prisma-schema
3
4generator client {
5 provider = "prisma-client-js"
6}
7
8datasource db {
9 provider = "sqlite"
10 url = env("DATABASE_URL")
11}
Finally, recall that when using Prisma with Next.js, we will often get the There are already 10 instances of Prisma Client actively running.
error under the dev environment, as discussed in the database integration lesson.
This error is caused by the hot module reloading (HMR) feature. Essentially, every time the app reloads, a new Prisma instance will be created, and quickly reach the maximum number of allowed instances for Prisma.
This issue can be addressed by creating a Prisma singleton. Create a db.js
file under libs
:
1src
2├── app
3│ ├── api
4│ ├── favicon.ico
5│ ├── globals.css
6│ ├── layout.jsx
7│ └── page.jsx
8├── components
9└── libs
10 ├── auth.js
11 └── db.js <=====
Create and export a Prisma singleton like this:
libs/db.js
1import { PrismaClient } from "@prisma/client";
2
3const prismaClientSingleton = () => {
4 return new PrismaClient();
5};
6
7const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
8
9export default prisma;
10
11if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;
Adding database adapter
Next, we must make sure that Prisma works with Auth.js, which can be achieved by creating a Prisma adapter.
1npm install @auth/prisma-adapter
Then go to auth.js
, our configuration file for Auth.js, and add the Prisma adapter option:
libs/auth.js
1import NextAuth from "next-auth";
2
3import { PrismaAdapter } from "@auth/prisma-adapter";
4import prisma from "@/libs/db";
5
6export const { handlers, signIn, signOut, auth } = NextAuth({
7 adapter: PrismaAdapter(prisma), // Add Prisma adapter
8 providers: [],
9});
The PrismaAdapter()
accepts one argument, which is the prisma
we create above.
There are no further configurations required, the Prisma adapter will take care of all interactions with the database for you.
Applying database schema
There is one last thing to do to set up the database side of things, and that is to create and apply the database schema. It is a blueprint that specifies what tables should be created in our database.
Auth.js requires the following tables to be created for things to work. You can copy and paste them into your schema.prisma
file.
prisma/schema.prisma
1. . .
2
3model User {
4 id String @id @default(cuid())
5 name String?
6 email String? @unique
7 emailVerified DateTime?
8 image String?
9 accounts Account[]
10 sessions Session[]
11
12 createdAt DateTime @default(now())
13 updatedAt DateTime @updatedAt
14}
15
16model Account {
17 id String @id @default(cuid())
18 userId String
19 type String
20 provider String
21 providerAccountId String
22 refresh_token String?
23 access_token String?
24 expires_at Int?
25 token_type String?
26 scope String?
27 id_token String?
28 session_state String?
29
30 createdAt DateTime @default(now())
31 updatedAt DateTime @updatedAt
32
33 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
34
35 @@unique([provider, providerAccountId])
36}
37
38model Session {
39 id String @id @default(cuid())
40 sessionToken String @unique
41 userId String
42 expires DateTime
43 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
44
45 createdAt DateTime @default(now())
46 updatedAt DateTime @updatedAt
47}
48
49model VerificationToken {
50 identifier String
51 token String
52 expires DateTime
53
54 @@unique([identifier, token])
55}
You can add new columns in the tables, but it is best to not delete or edit anything, unless you know what you are doing.
Apply this schema to the database by running the following command:
1npx prisma migrate dev
If everything works, you should get the following output:
1Environment variables loaded from .env
2Prisma schema loaded from prisma/schema.prisma
3Datasource "db": SQLite database "database.sqlite" at "file:./database.sqlite"
4
5✔ Enter a name for the new migration: … initial
6Applying migration `20250210235322_initial`
7
8The following migration(s) have been created and applied from new schema changes:
9
10migrations/
11 └─ 20250210235322_initial/
12 └─ migration.sql
13
14Your database is now in sync with your schema.
15
16✔ Generated Prisma Client (v6.3.1) to ./node_modules/@prisma/client in 67ms
The schema will be applied and our prisma/database.sqlite
will look like this:
Adding a email provider
And now, we need a way for Auth.js to send verification emails. There are packages that allows us to set up our own email servers, but it is not recommended as the emails sent from person email servers will usually end up in the spam folder.
It is best to use a proper email delivery platform instead, and there are many options, such as Resend, Postmark, Sendgrid, and so on.
For this lesson, we'll use Resend, which a modern email delivery platform designed for developers. It focuses on providing a simple, developer-friendly API that integrates seamlessly with applications.
Go to https://resend.com/ and register a new account, and after that, you should see the onboarding page:
Click on the Add API Key button to generate an API key.
Copy and paste the key into your .env
file. Name the environmental variable as AUTH_RESEND_KEY
, so that Auth.js can automatically access it.
.env
1AUTH_SECRET="pr9JjImhp+lFbaeFx0MthkSO5idL0tjtavYcFDBE6h0=" # Added by `npx auth`. Read more: https://cli.authjs.dev
2DATABASE_URL="file:./database.sqlite"
3AUTH_RESEND_KEY="<your_api_key_here>"
In order for Resend to successfully delivering emails, you must verify the domain from which you want to send the emails.
Go to Domains, and click on Add Domain:
Submit your domain, and you should see the following instructions:
Go to your domain hosting platform and add the DNS records according to the previous instruction:
It will take up to 24 hours for the DNS records to update, and after that, you should see that your domain has been verified on Resend.
Lastly, go to the auth.js
and add Resend as a provider. Don't forget to provide the email address that you want the emails to be sent from.
libs/auth.js
1import NextAuth from "next-auth";
2
3import { PrismaAdapter } from "@auth/prisma-adapter";
4import prisma from "@/libs/db";
5
6import Resend from "next-auth/providers/resend";
7
8export const { handlers, signIn, signOut, auth } = NextAuth({
9 adapter: PrismaAdapter(prisma),
10 providers: [
11 Resend({
12 from: "auth@send.thedevspace.io",
13 }),
14 ],
15});
Creating a sign in page
Things are mostly ready, we have prepared the database adapter and the email provider. All we need now is a interface that allows the user to sign in.
Create a sign in page like this:
1src
2├── app
3│ ├── api
4│ ├── favicon.ico
5│ ├── globals.css
6│ ├── layout.jsx
7│ ├── page.jsx
8│ └── signin
9│ └── page.jsx <=====
10├── components
11└── libs
app/signin/page.jsx
1import { signIn } from "@/libs/auth";
2
3export default function SignIn() {
4 return (
5 <div className=". . .">
6 <form
7 className=". . ."
8 action={async (formData) => {
9 "use server";
10 await signIn("resend", formData);
11 }}>
12 <div className="mb-6">
13 <label htmlFor="email" className=". . .">
14 Email
15 </label>
16 <input
17 type="text"
18 name="email"
19 id="email"
20 placeholder="Enter your email"
21 className=". . ."
22 />
23 </div>
24 <button type="submit" className=". . .">
25 Sign in with Resend
26 </button>
27 </form>
28 </div>
29 );
30}
This page contains a form with a input field that allows the user to provide their email address, and a button to submit this form. Styles are removed from this example for simplicity.
Take a closer look at the example and notice that when the form is submitted, a server action function will be executed:
1async (formData) => {
2 "use server";
3 await signIn("resend", formData);
4};
Recall that we exported a signIn()
utility function from auth.js
, and this is where we are going to use it. Inside the server action, the signIn()
function will be executed with two arguments, "resend"
and formData
.
In the background, Auth.js will automatically read the formData
, get the user's email, and send the verification email to their inbox.
If everything works correctly, the user will be redirected to the following page after successfully submitting the form. For most businesses, this page is not ideal and does not reflect the company branding, but don't worry, will discuss how to customize it later.
And the user will receive an email in their inbox. Again, this email is not ideal, and we'll learn to customize later.
Click on the verification link and you will be taken to the corresponding API point (app/api/auth/[...nextauth]/route.js
), which we set up before. Auth.js will then verify the user in the background.
If the user does not exist, a new user will be created and saved in the database:
Custom "check your email" page
Now, let's talk about how to customize the Check Your Email page we just saw. Auth.js allows you to define custom pages to replace the default user interfaces, by specifying a pages
option in the auth.js
file.
For our Check Your Email page, referred to as the verify request page in Auth.js, you need to provide the pages.verifyRequest
option, and it should point to the URL where you want to create the page. For example:
libs/auth.js
1import NextAuth from "next-auth";
2
3import { PrismaAdapter } from "@auth/prisma-adapter";
4import prisma from "@/libs/db";
5
6import Resend from "next-auth/providers/resend";
7
8export const { handlers, signIn, signOut, auth } = NextAuth({
9 adapter: PrismaAdapter(prisma),
10 providers: [
11 Resend({
12 from: "auth@send.thedevspace.io",
13 }),
14 ],
15 pages: {
16 verifyRequest: "/check-your-email", // Specify the URL for the custom "Check Your Email page"
17 },
18});
Then create the page.jsx
under the right route, and in this case, it is app/check-your-email
.
1src/app
2├── api
3│ └── auth
4│ └── [...nextauth]
5│ └── route.js
6├── check-your-email
7│ └── page.jsx <===== The "Check Your Email" page
8├── favicon.ico
9├── globals.css
10├── layout.jsx
11├── page.jsx
12└── signin
13 └── page.jsx
Finally, design the page just like any other pages.
app/check-your-email/page.jsx
1export default function CheckYourEmail() {
2 return (
3 <div className="flex min-h-[80vh] items-center justify-center bg-gray-100">
4 <div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-md">
5 <h1 className="mb-4 text-2xl font-bold text-gray-800">
6 Check Your Email
7 </h1>
8 <p className="mb-6 text-gray-600">
9 We've sent a sign-in link to your email address. Please check your
10 inbox and click the link to continue.
11 </p>
12 <div className="flex justify-center">. . .</div>
13 </div>
14 </div>
15 );
16}
Now, go back to the sign in page, type in the email address and hit Enter
, you will be take to the new, customized Check Your Email page.