When building web applications, authentication is almost always a core requirement. I needed something simple and secure that would let me get started quickly on new projects without over-complicating the setup. My goal was to have a solution that's easy to implement, handles email verification out of the box, and just works. In this post, I'm sharing how to build a straightforward magic link authentication system using Next.js, Auth.js, Prisma, PostgreSQL, and Resend. It's a powerful yet simple solution that accomplishes exactly what I needed, and I hope it'll be useful for your projects too.
You can find the starter kit, which implements the setup described in this blog post, in the following GitHub repository.
Getting Started
Installing Dependencies
To get started, initialize a new Next.js app:
npx create-next-app@latest
For this setup, I've configured the new app named nextjs-magic-link-auth
to use TypeScript, ESLint, Tailwind, a /src
directory, the App Router, and a custom import alias @/*
for cleaner imports.
Prisma Setup
The next step is to set up the Prisma ORM and connect it to the database. I've chosen PostgreSQL for its strong support for relational data, ease of integration with Prisma, and reliability for production use.
cd nextjs-magic-link-auth
yarn add @prisma/client
yarn add prisma --dev
Once Prisma is installed, initialize it and create a schema.prisma
file by running:
yarn prisma init
Next, set up the DATABASE_URL
in your .env
file (i.e., touch .env
). Make sure to replace <username>
, <password>
, and <db_name>
with your actual database credentials:
DATABASE_URL="postgresql://<username>:<password>@localhost:5432/<db_name>?schema=public"
Now, update the schema.prisma
file to define the tables and columns needed for email magic link authentication. For this example, I've only added the essentials, but you might want to refer to the Auth.js Prisma documentation to tailor it to your needs.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime? @map("email_verified")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
@@index([email])
@@map("users")
}
model VerificationToken {
id String @id @default(cuid())
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
@@unique([identifier, token])
@@map("verification_tokens")
}
After updating the schema.prisma
file, run the following commands to create the migration and apply the changes to your database. This ensures that your schema is correctly set up:
yarn prisma migrate dev --name init
The migrate dev
command will create a new migration file in the prisma/migrations
directory, allowing you to track changes to your schema over time.
Once the migration is complete, create a new file called prisma.ts
in your src/lib
directory to configure the Prisma client:
mkdir src/lib
touch src/lib/prisma.ts
Add the following code to prisma.ts
:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
The code creates a single instance of PrismaClient
and attaches it to the global object when not in production to avoid multiple client instances during development. In production, the client is not stored on the global object (globalForPrisma
), meaning each module that imports prisma
will use a fresh instance from new PrismaClient()
.
Configure the Resend Provider with Prisma
Once Prisma is set up, it's time to integrate it with Resend. If you don't have a Resend account yet, start by creating one and generating an API key. Save this API key in your .env
file as AUTH_RESEND_KEY
:
AUTH_RESEND_KEY=<your-resend-api-key>
Next, install Auth.js and its Prisma adapter by running:
yarn add next-auth@beta @auth/prisma-adapter
After installing these dependencies, add the AUTH_SECRET
environment variable to your .env
file. You can generate a random secret by running:
openssl rand -base64 32
This AUTH_SECRET
is used by Auth.js to encrypt tokens and email verification hashes securely.
Now, it's time to set up the Auth.js configuration. Create a folder and file to hold the configuration:
mkdir src/lib/auth
touch src/lib/auth/index.ts
Fill in src/lib/auth/index.ts
with the following content:
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import Resend from "next-auth/providers/resend";
export const authOptions: NextAuthConfig = {
adapter: PrismaAdapter(prisma),
providers: [
Resend({
from: "onboarding@resend.dev",
}),
],
session: {
strategy: "jwt",
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
Next, add the route handler for Auth.js under src/app/api/auth/[...nextauth]/route.ts
:
mkdir -p src/app/api/auth/\[...nextauth\]
touch src/app/api/auth/\[...nextauth\]/route.ts
Fill it in with the following content:
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
And that's it! Run yarn dev
, then head over to http://localhost:3000/api/auth/signin
to sign in using your email. Check your inbox for the sign-in email, click on the link provided, and you'll be signed in.
I've also included a GitHub repository that showcases the implementation of this setup.
Conclusion and Further Improvements
This simple magic link authentication system with Next.js, Prisma, and Resend provides a solid foundation for secure and easy-to-implement authentication. To further enhance this setup, consider the following improvements:
- Secure sensitive routes using Next.js middleware to prevent unauthorized access (read more here).
- Log emails to the console locally instead of sending them in a development environment to avoid unnecessary Resend API calls:
return Resend({
from: "onboarding@resend.dev",,
// Send email verification requests to the console in development to avoid
// spamming the Resend API
...(process.env.NODE_ENV === "development"
? {
sendVerificationRequest: async ({ identifier, url, provider }) => {
const { host } = new URL(url);
console.log(`
----------------------------------
From: ${provider.from}
To: ${identifier}
Subject: Sign in to ${host}
Sign in URL:
${url}
----------------------------------
`);
},
}
: {}),
});
- Implement rate limiting to prevent potential abuse by limiting the number of sign-in requests within a specified timeframe (read more here).
These additions will help make the authentication flow more robust and production-ready while maintaining a simple codebase.
Top comments (1)
your blog helped a brother out!