Implementing Magic Links with Resend, Kysely and Next-Auth

Revising my original article on implementing magic links with Next-Auth, NextJS and Prisma to use Resend and Kysely instead.

Published 14 August, 2023

Introduction

I recently wrote an article on how to implement magic links with Next-Auth, NextJS and Prisma. I've since discovered a couple of libraries that make the process a lot easier. This article will show you how to implement magic links with Next-Auth, NextJS, Resend and Kysely.

I fixed everything together into a template which you can clone and use to start your own project here

I've always prefered kysely over Prisma since it gives me much more flexibility and is significantly faster on the edge. So I figured that I would switch over to using their new adapter once it was released.

Next auth handles the authentication for us. When a user tries to login, we send them an email with a link. The link contains a token that is valid for 5 minutes. When the user clicks the link, we verify the token and log them in. Resend takes care of the email sending and Kysely takes care of the database queries.

Setup

I've already written up most of the code here so this will be a quick walkthrough instead of a comprehensive tutorial.

You can clone my repo and run yarn to install the dependencies.

git clone https://github.com/ivanleomk/next-auth-resend-kysely
cd next-auth-resend-kysely
npm install

Environment variables

We'll need to set up a few environment variables. Create a .env file in the root of your project and add the following. Resend values are pulled from their documentation on SMTP Sending

DATABASE_URL= 
RESEND_API_KEY=
 
# Next Auth Config
NEXTAUTH_URL=
NEXTAUTH_SECRET=
 
# Resend Configuration
EMAIL_SERVER_USER=resend
EMAIL_SERVER_HOST=smtp.resend.com
EMAIL_SERVER_PORT=25
EMAIL_FROM= #Email Address from a domain that you own

Before you can send any emails, you'll need a domain name. In my case, I'm using my own domain ivanleo.com. Once you've done so, just head over to Resend and sign up for an account.

Once you've signed up, you'll need to verify your domain - just follow the instructions on Resend to do so. Once you've verified your domain, you'll need to get hold of a database u can use. I recommend using Supabase but in this example, I'm using Neon which provides a serverless Postgres instance for you to use.

If you're using something like Neon or Supabase, make sure to use a pooled connection. This is because Next Auth will use a serverless lambda by default to connect to your database which means that during peak traffic, it might cause you to run out of connections.

Once you've done with these two steps, you should have

  1. A database url : This should look like postgres://<url here>. Make sure to append ?pgbounder=true to the end of your database url if you're using neon. For some reason, this helps prisma to solve some connection issues, not sure why.
  2. A Resend API key

Database Configuration

Next, you'll need to configure your database. For this, I like using Prisma for a few reasons

  1. It's the easiest tool to use to setup a database - just use npx prisma db push
  2. Prisma Studio is honestly a fantastic tool to explore your database
  3. Kysely provides an easy tool to hook into the database generation proccess - Prisma Kysely

If you've cloned the repo above, the file is located in prisma/schema.prisma. If you're using your own project, you can just copy the file over.

auth.ts

Once you've initialised this, you can then proceed to run the command

npx prisma db push 

Prisma will then connect to your database, configure the tables and then generate the relevant kysely types. Note here that we're using @default(dbgenerated("gen_random_uuid()")) to generate our unique primary key for our Account, User and Session tables.

Next Auth Configuration

Now that we've configured our database, let's now configure NextAuth. To do so, we'll need to create a file called [...nextauth].ts in our pages/api/auth folder.

auth.ts

 
import { NextAuthOptions } from "next-auth";
import EmailProvider from "next-auth/providers/email";
import { Resend } from 'resend';
import { KyselyAdapter } from "@auth/kysely-adapter";
import { db } from "@/lib/db";
import NotionMagicLinkEmail from "../../emails/emails/notion-magic-link";
import { siteConfig } from "./site";
 
 
const resend = new Resend(process.env.RESEND_API_KEY);
 
 
const providerConfig = EmailProvider({
  server: {
    host: process.env.EMAIL_SERVER_HOST,
    port: process.env.EMAIL_SERVER_PORT,
    auth: {
      user: process.env.EMAIL_SERVER_USER,
      pass: process.env.RESEND_API_KEY,
    },
  },
  from: process.env.EMAIL_FROM,
 
  sendVerificationRequest: async ({ identifier, url, provider }) => {
    try {
      const isDevOrStaging = process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview"
      const emailAddress = isDevOrStaging ? "delivered@resend.dev" : identifier;
 
 
      //@ts-ignore
      const data = await resend.emails.send({
        from: // fill this in,
        to: [emailAddress],
        subject: `Your welcome email to ${siteConfig.name}`,
        react: NotionMagicLinkEmail({ loginUrl: url }),
        headers: {
          "X-Entity-Ref-ID": new Date().getTime() + "",
        }
      });
 
      console.log(data);
    } catch (error) {
      console.error(error);
    }
  },
});
 
export const authOptions: NextAuthOptions = {
  providers: [providerConfig],
  session: {
    strategy: "jwt",
  },
  //@ts-ignore
  adapter: KyselyAdapter(db),
  pages: {
    signIn: "/login"
  }
}

We first create a new resend object

const resend = new Resend(process.env.RESEND_API_KEY);

We then configure it such that we use the email provider

const providerConfig = EmailProvider({
  server: {
    host: process.env.EMAIL_SERVER_HOST,
    port: process.env.EMAIL_SERVER_PORT,
    auth: {
      user: process.env.EMAIL_SERVER_USER,
      pass: process.env.RESEND_API_KEY,
    },
  },
  from: process.env.EMAIL_FROM,
 
  sendVerificationRequest: async ({ identifier, url, provider }) => {
    try {
      const isDevOrStaging = process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview"
      const emailAddress = isDevOrStaging ? "delivered@resend.dev" : identifier;
 
 
      //@ts-ignore
      const data = await resend.emails.send({
        from: // fill this in,
        to: [emailAddress],
        subject: `Your welcome email to ${siteConfig.name}`,
        react: NotionMagicLinkEmail({ loginUrl: url }),
        headers: {
          "X-Entity-Ref-ID": new Date().getTime() + "",
        }
      });
 
      console.log(data);
    } catch (error) {
      console.error(error);
    }
  },
});

Note that we configure our provider such that whenever we are in a non-prod environment, we send it to the resend test email address at delivered@resend.dev.

const isDevOrStaging = process.env.NODE_ENV === "development" || process.env.VERCEL_ENV === "preview"
const emailAddress = isDevOrStaging ? "delivered@resend.dev" : identifier;

You can find the test email I'm using here written in React-Email.

We then configure our NextAuth options such that we use the Kysely adapter. I couldn't get the types to match right so I had to use //@ts-ignore to ignore the types. But I've tested it and it works fine so far.

export const authOptions: NextAuthOptions = {
  providers: [providerConfig],
  session: {
    strategy: "jwt",
  },
  //@ts-ignore
  adapter: KyselyAdapter(db),
  pages: {
    signIn: "/login"
  }
}

If you haven't installed the Kysely Adapter, you can do so by running the command which was taken from the Auth.Js documentation

npm install kysely @auth/kysely-adapter

Middleware

Lastly, we just need to add in a middleware.ts file in our src folder and next auth will work as needed ( Already included in the repo )

export { default } from "next-auth/middleware";
 
export const config = {
  matcher: ["/dashboard/:path*"],
};

NextAuthProvider

Now we need to add in the NextAuthProvider to our layout.tsx file so that we can configure some of the client side hooks such as getSession. To do so, just create a new file called NextAuthProvider

NextAuthProvider.tsx

"use client";
 
import { SessionProvider } from "next-auth/react";
 
type Props = {
  children?: React.ReactNode;
};
 
export const NextAuthProvider = ({ children }: Props) => {
  return <SessionProvider>{children}</SessionProvider>;
};

We can then add it into our root layout file at src/layout.tsx

import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Header from '@/components/layout/Header'
import { Inter as FontSans } from "next/font/google"
import localFont from "next/font/local"
import { cn } from '@/lib/utils'
import { Toaster } from '@/components/ui/toaster'
import { siteConfig } from '@/config/site'
import { NextAuthProvider } from '@/provider/NextAuthProvider'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
 
  return (
    <html lang="en" suppressHydrationWarning>
      <head />
      <NextAuthProvider>
        <body>
        // Other defied values here
        </body>
      </NextAuthProvider>
    </html>
  )
}

and we can now use the hooks provided by next auth such as useSession and getSession in our application.

Adding an ID to the session

We've got the bare bones of next auth done - now there's just one small problem left. Whenever we call getServerSession() or getSession(), we get a session object that consists of the following type

type Session = {
  name: string;
  email: string;
  picture: string;
}

This is nice but we're missing the id field which we need to query our database. To do so, we'll need to add in a custom jwt and session callback to our auth.ts file.

// rest of the auth.ts file 
export const authOptions: NextAuthOptions = {
  providers: [providerConfig],
  session: {
    strategy: "jwt",
  },
  //@ts-ignore
  adapter: KyselyAdapter(db),
  pages: {
    signIn: "/login"
  },
  callbacks: {
    session: async ({ session, token, user }) => {
      if (token) {
        session.user.id = token.id
        session.user.name = token.name
        session.user.email = token.email
        session.user.image = token.picture
      }
 
      return session
    },
    jwt: async ({ token, user }) => {
      if (!token.email) {
        return token
      }
 
      const dbUser = await db.selectFrom("User").where("email", "=", token.email).selectAll().executeTakeFirst()
 
      if (!dbUser) {
        if (user) {
          token.id = user?.id
        }
        return token
      }
 
      return {
        id: dbUser.id,
        name: dbUser.name,
        email: dbUser.email,
        picture: dbUser.image,
      }
    },
  }
}

This will in turn ensure that whenever we fetch user information, we will get the id along with the rest of the data. We now need to add in a new type definition, so create a new file called next-auth.d.ts in your types folder

NextAuth Type

import { User } from "next-auth"
import { JWT } from "next-auth/jwt"
 
type UserId = string
 
declare module "next-auth/jwt" {
  interface JWT {
    id: UserId
  }
}
 
declare module "next-auth" {
  interface Session {
    user: User & {
      id: UserId
    }
  }
}

Let's also create a nifty utility function that wraps getServerSession with the right authOptions. This is because getServerSession without the authOptions will return the session object without the id field, so for convinience, we define a new function which does this for us.

import { authOptions } from "@/config/auth";
import { getServerSession } from "next-auth"

export const getUser = async () => {
  const session = await getServerSession(authOptions);
  if (!session) {
    return null
  }
  return session.user
}

Conclusion

I hope this helped quite a fair bit in setting up authentication in your web application. I spent a good 1-2 days trying to figure out everything and this would have really been useful for me.