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
Original exploration was inspired by Dax's tweet below
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
- 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. - A Resend API key
Database Configuration
Next, you'll need to configure your database. For this, I like using Prisma for a few reasons
- It's the easiest tool to use to setup a database - just use
npx prisma db push
Prisma Studio
is honestly a fantastic tool to explore your databaseKysely
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.