Saas User Auth

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:

bash
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.

bash
1npm install @prisma/client

Finally, initialize Prisma.js to use SQLite:

bash
1npx prisma init --datasource-provider sqlite
text
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.

text
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

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

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:

text
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

javascript
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.

bash
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

javascript
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

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:

bash
1npx prisma migrate dev

If everything works, you should get the following output:

text
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:

Database structure

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:

Resend Onboarding

Click on the Add API Key button to generate an API key.

Resend Get 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

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:

Resend Domains

Submit your domain, and you should see the following instructions:

Resend Add Domain

Go to your domain hosting platform and add the DNS records according to the previous instruction:

Setting up DNS Records

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.

Domain Verified

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

javascript
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:

text
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

javascript
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}

Sign In Form

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:

javascript
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.

Check your email

And the user will receive an email in their inbox. Again, this email is not ideal, and we'll learn to customize later.

Verification Email

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:

New user created

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

javascript
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.

text
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

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.

Custom Check Your Email