Profile picture

Curious software engineer with a keen interest in craftsmanship and design principles. At Remote, I work with a great team to create delightful products, ensuring everything runs smoothly under the hood, aiming to enable global remote work.


Improvising bits and melodies @diegocasmo.


Simple Next.js Magic Link JWT Authentication with Prisma, PostgreSQL, and Resend

October 07, 2024

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.