Magic Link Authentication with SendGrid and Auth.js

November 14, 2023
Written by
Avinash Prasad
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

With traditional password-based logins, users often need to go through a "password reset" process when they forget their credentials. This creates an additional step and potential vulnerability. Not only does it introduce security risks, but it can also sabotage user experience by requiring frequent password resets. That's where “magic link authentication” comes to the rescue.

Magic Link Authentication is a passwordless login method that allows users to access your application by clicking a unique link sent to their email. It eliminates the need to remember passwords and provides a secure and user-friendly way to verify user identity.

In this blog post, you will build a passwordless authentication system using Next.js 13 (with the new app router), SendGrid, and Auth.js. With this system, users can access your application by simply clicking on a link sent to their email.

Prerequisites

In order to follow along with this tutorial, you will need the following:

Setting up the project

Start by creating a new Next.js 13 project using the following command:

npx create-next-app@13.4 magic-link-auth

You will be prompted to select settings for the new project, make sure to follow these choices:

  • For TypeScript, select No because this tutorial uses JavaScript.
  • Feel free to choose any option for enabling ESLint.
  • Choose Yes for Tailwind CSS.
  • Exclude the src/ directory by selecting No, which is the default option.
  • Enable App Router by selecting Yes, which is the recommended option.
  • Lastly, select No for import alias configuration.

Change the working directory to your new Next.js project, magic-link-auth, and run the application to make sure everything is fine:

cd magic-link-auth
npm run dev

Building the Homepage and Sign in Page

Before you start building the Homepage and Sign in page for this demo application, here’s  an example of how routing works in Next.js 13’s new app directory:

app
├── signin
│     ├── page.js
├── layout.js
├── page.js

Next.js 13 uses a router where folders are used to define routes. A special page.js file is used to make the route segment, which maps to a URL, publicly accessible, so the app/signin/page.js defines the UI for the /signin route.

The app/page.js file in your project defines the UI for the / route. Open up the newly created project directory on your preferred IDE and replace all the code in the app/page.js with the following code:

'use client'

import { useRouter } from "next/navigation";

export default function Home(context) {

  const router = useRouter()

  return (
    <main>
      <div className="container px-5 py-24 mx-auto flex flex-col justify-center items-center text-center">
        <h1>Homepage</h1>
        <button onClick={() => router.push('/signin')}>Sign in with Email</button>
      </div>
    </main>
  )
}

(You can also remove all the code from global.css except the first three lines of code.)

Now, if you go to the / route of your application, you should see “Homepage” written at the center of the screen and below it, a button which says “Sign in with Email”. If you click on it, it should navigate to the /signin route. But you don’t have it yet in your application.


Create a new folder in the /app directory called signin and within it a new file called page.js with the following code:

'use client'

export default function Signin() {

  return (
    <main>
      <div className="container px-5 py-24 mx-auto flex justify-center">
        <div className="bg-slate-100 rounded-lg p-8 flex flex-col w-1/2 mt-10 md:mt-0 relative z-10 shadow-md">
          <h2 className="text-gray-900 text-lg mb-1 font-medium title-font">🌐 Magic Link</h2>
          <p className="leading-relaxed mb-5 text-gray-600">Sign in by clicking a link in the email</p>
          <div className="relative mb-4">
            <label className="leading-7 text-sm text-gray-600">Email</label>
            <input type="email" id="email" name="email" className="w-full bg-white rounded border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out" />
          </div>
          <button className="text-white bg-blue-500 border-0 py-2 px-6 focus:outline-none hover:bg-blue-600 rounded text-lg">Log in / Sign up</button>
        </div>
      </div>
    </main>
  )
}

If you go the /signin route of your application you should see a dialog box which prompts the user to input email for signing in.

Setting up NextAUTH

NextAUTH is a powerful authentication library specifically tailored for Next.js. Open a new tab on your terminal and enter the following command to install the next-auth and nodemailer packages:

npm install next-auth nodemailer

Create a new file named route.js within the directory structure app/api/auth/[...nextauth], (you will need to create these three nested folders), and add this code:

import NextAuth from "next-auth/next";
import EmailProvider from "next-auth/providers/email";

const authOptions = {
    providers: [
        EmailProvider({
            server: {
                host: process.env.EMAIL_SERVER_HOST,
                port: process.env.EMAIL_SERVER_PORT,
                auth: {
                    user: process.env.EMAIL_SERVER_USER,
                    pass: process.env.EMAIL_SERVER_PASSWORD,
                }
            },
            from:process.env.EMAIL_FROM,
        })
    ],
    pages: {
        signIn: '/signin',
    },
    session: {
        strategy: 'jwt',
    },
    jwt: {
        secret: process.env.NEXTAUTH_JWT_SECRET,
    },
    secret: process.env.NEXTAUTH_SECRET
}

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST };

Here, the authOptions object defines the authentication options and settings for NextAuth handler. providers is an array that specifies the authentication provider(s) to use. In this case, it's using the EmailProvider for email-based authentication.

Wrap all your components with a SessionProvider

NextAuth.js handles user authentication as well as session management. To ensure that all components or pages in your application have access to session-related data you need to wrap all your components in layout.js with a SessionProvider.

In Next.js, a layout is UI that is shared between routes. The root layout (app/layout.js) is the top-most layout in the root app directory and is used to define the <html> and <body> tags and other globally shared UI.

SessionProvider is a component provided by the NextAuth.js library that helps manage user sessions and authentication state in your application. When a user successfully authenticates through one of these providers, the SessionProvider helps manage the user's session and provides access to their authentication state and user data.

But when you try to use SessionProvider directly within the body of layout.js, it might not work as expected because the client-side context is not available at that point in the HTML structure. You can use a work-around.

Create a folder named components within the app folder and then create a file named Provider.js in the app/components directory. It will work as a custom Provider component that wraps the SessionProvider. When you use this Provider component to wrap your children, it effectively ensures that the SessionProvider is used on the client side. Add this code to Provider.js:

'use client'

import { SessionProvider } from "next-auth/react"

const Provider = ({children}) => {
    return <SessionProvider>{children}</SessionProvider>
}

export default Provider;

Now within the layout.js file, update the RootLayout() function by importing the Provider component and wrapping the {children} with <Provider></Provider>:

import './globals.css'
import { Inter } from 'next/font/google'
import Provider from './components/Provider'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Magic Link',
  description: 'Passwordless Authentication System using Nextjs 13, SendGrid and NextAUTH',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Provider>
          {children}
        </Provider>
      </body>
    </html>
  )
}

While you are at it you can also modify the metadata of your app in layout.js.

You’ll need to define the following environment variables in your .env file in the root directory of your project:

EMAIL_SERVER_HOST=
EMAIL_SERVER_PORT=
EMAIL_SERVER_USER=
EMAIL_SERVER_PASSWORD=
EMAIL_FROM=

NEXTAUTH_JWT_SECRET="NEXT-JWT-SECRET"
NEXTAUTH_SECRET="NEXT-SECRET"
NEXTAUTH_URL=http://localhost:3000/

DATABASE_URL=

If you don’t already have a .env file in the root of your project directory, create one and paste the above code snippet inside it.

To set the values of these variables you need to get the API key and other information from your Twilio SendGrid account which you’ll see in the section.

Preparing the SMTP Server using Twilio SendGrid

SMTP stands for Simple Mail Transfer Protocol. It is a standardized protocol used for sending and receiving email messages between email servers. For your magic link authentication system to work seamlessly, you need a reliable email delivery service. Twilio SendGrid is an excellent choice for this purpose.

  • Go to the SendGrid website.
  • For this tutorial, select a free plan, provide required details, and sign up!

After signing up, navigate to the Dashboard, and on the left sidebar go to : Settings > Sender Authentication.

Perform the sender authentication (single sender verification in this case) by verifying your email address. After verification, create a new sender by filling in the required details. Once the verification is done, add that email to the .env file in your application as the value for EMAIL_FROM.

In your SendGrid Dashboard on the left sidebar, select Email API > Integration Guide. You will find two options: Web API and SMTP Relay. Select SMTP Relay.

SendGrid Integration Guide Panel

Create an API key by entering a name for your first API key, you can set it as
“apikey” for this tutorial. You should get your API key which starts with “SG”.

Copy your newly created API key immediately and paste it as the value of EMAIL_SERVER_PASSWORD in your .env file. Here are the values you should be adding for the rest of your environment variables:

EMAIL_SERVER_HOST=smtp.sendgrid.net
EMAIL_SERVER_PORT=465
EMAIL_SERVER_USER=apikey
EMAIL_SERVER_PASSWORD="add sendgrid api key here"
EMAIL_FROM="add the email your verified here"

NEXTAUTH_JWT_SECRET="NEXT-JWT-SECRET"
NEXTAUTH_SECRET="NEXT_SECRET"
NEXTAUTH_URL=http://localhost:3000/

DATABASE_URL=

You can use those values for NEXTAUTH_JWT_SECRET and NEXTAUTH_SECRET as placeholders during development or as part of your initial configuration, but it's crucial to replace them with secure and unique values in a production environment.

The last empty environment variable you have in your .env file is DATABASE_URL, you will take care of that in the next section.

Integrating MongoDB Database

You can use MongoDB to store user data, authentication tokens, and session information. To connect your Next.js application with a MongoDB database, all you need is a connection string. There are two ways to set up a MongoDB cluster and acquire a connection string.

The first option is to host your MongoDB cluster in the cloud using MongoDB Atlas. The second option is to host MongoDB locally on your development machine, which is suitable for testing and development purposes.

Paste the connection string for your mongodb database in your .env file as the value for DATABASE_URL, it should look something like this: “mongodb+srv://username:password@cluster0.bmw9merc.mongodb.net/”

Next, navigate to your terminal and install mongodb and mongodb adapter in your project:

npm install @auth/mongodb-adapter mongodb

Add the MongoDB client

The MongoDB adapter does not handle connections automatically, so you will have to make sure that you pass the adapter to the MongoClient that is connected already. Create a folder called lib within app/api/auth/[...nextauth] and within the lib folder create a file called mongodb.js and add this code (this is directly taken from the Auth.js documentation):

import { MongoClient } from "mongodb";

if (!process.env.DATABASE_URL) {
  throw new Error('Invalid/Missing environment variable: "DATABASE_URL"');
}

const uri = process.env.DATABASE_URL;
const options = {};

let client;
let clientPromise;

if (process.env.NODE_ENV === "development") {
  // In development mode, use a global variable so that the value
  // is preserved across module reloads caused by HMR (Hot Module Replacement).
  if (!global._mongoClientPromise) {
    client = new MongoClient(uri, options);
    global._mongoClientPromise = client.connect();
  }
  clientPromise = global._mongoClientPromise;
} else {
  // In production mode, it's best to not use a global variable.
  client = new MongoClient(uri, options);
  clientPromise = client.connect();
}

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;

This code sets up and exports a MongoDB client instance, checks for the DATABASE_URL environment variable, sets connection options, and manages the client instance. The exported clientPromise resolves to the connected MongoDB client, ensuring that the application can establish connections to the MongoDB database.

Configure NextAUTH for MongoDB

Finally you need to configure NextAuth.js to use MongoDB as the authentication data store and provide access to the MongoDB client.

Add imports for the Mongodb adapter and clientPromise at the top of your route.js file in your app\api\auth\[...nextauth] directory:

import { MongoDBAdapter } from "@auth/mongodb-adapter";
import clientPromise from "./lib/mongodb";

Add this configuration in the authOptions object, just above the providers array:

adapter: MongoDBAdapter(clientPromise),

Final Steps

Now that you have set up NextAUTH, Sendgrid and MongoDB, you just need to implement NextAUTH methods in your /signin and / routes.

Sign in page

In your app/signin/page.js make the following imports:

import { signIn } from "next-auth/react";
import { useState } from "react";

And add this just above the return statement within the SignIn() function:

const [email, setEmail] = useState('');

Here, you're using the useState hook to create an email state variable and an associated setEmail function. This state will be used to manage the value entered into the email input field.

Replace the existing  input element within SignIn() with the following input element:

<input onChange={(e) => setEmail(e.target.value)} type="email" id="email" name="email" className="w-full bg-white rounded border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 text-base outline-none text-gray-700 py-1 px-3 leading-8 transition-colors duration-200 ease-in-out" />

The <input> element for the email address is modified to include an onChange event handler. When the user types into the input field, the onChange event handler is triggered, and it updates the email state variable with the current input value using setEmail(e.target.value). This ensures that the email input value is dynamically tracked as the user types.

Then, replace the button element with the following to add an onClick event handler to the “Log in/ Sign up” button:

<button onClick={() => signIn('email', {email, callbackUrl:'/'})} className="text-white bg-blue-500 border-0 py-2 px-6 focus:outline-none hover:bg-blue-600 rounded text-lg">Log in / Sign up</button>

When the "Log in / Sign up" button is clicked, it triggers the signIn function from NextAuth.js. It specifies the 'email' provider, indicating that the user intends to sign in or sign up using their email address. The email state variable holds the value entered by the user in the email input field, and callbackUrl is set to '/' as the destination URL after authentication.

Homepage

In this section, we will be making modifications to the homepage of our Next.js application. Start by adding the following imports to your app/page.js file:

import { signOut, useSession } from "next-auth/react";

Just above the return statement add:

const {data: session} = useSession(context)

The useSession hook is used to retrieve session data from the context. This data includes information about the authenticated user, such as their email.

Then, replace the button element below h1 element with:

{session && 
          <>
            <div>
              <p>Signed in as {session.user.email}</p>
              <button onClick={signOut}>Sign Out</button>     
            </div>
                   
          </>}

{!session &&
          <>
            <button onClick={() => router.push('/signin')}>Sigin with Email</button>
          </>
}

Here, conditional rendering is used based on the presence of a “session”. A session refers to the authenticated user's state and the associated data that is stored for that user during their authenticated session.

If a session exists, the homepage displays the user's email and a "Sign Out" button. When the "Sign Out" button is clicked, it triggers the signOut function to log the user out. If there is no session, it displays a "Sign in with Email" button.

Test your work

Save and close all your files. In your command prompt, navigate to your project’s root folder, magic-link-auth.

Run the following command to start your local server:

npm run dev

Once your app is running, head to your browser and visit localhost:3000 (or whatever your port is), and click on Signin with Email, you should see the signin route:

UI of /signin route

Type your email and click on the blue Login button:

Using Signin Feature

You should see this message on the screen:

Notification that signin email has been sent.

Check the inbox of your email and you should get a login link:

Signin Email Recieved.

When you click on the “Sign in” button on that email, you will be authenticated and a new user will be created in your mongoDB database. You will be redirected to the “/” route and should see the email that you signed in with written on the screen:

Homepage after logging in.

If you click on “Sign Out” your user session will end and the Homepage components will reload to display the “Sign In” button.

Conclusion

Congratulations! You have successfully created a magic link authentication system using Nextjs 13, NextAUTH and SendGrid. The source code for this project can be found here: Magic Link Authentication.

What’s next?

  • Add features in the home page
  • Improve upon the design of the application.
  • Create a custom “Email Sent” Notification page.

Avinash Prasad is a Software Developer and Content Creator. He has a passion for simplifying complex technical topics, making them accessible to a wide audience. He is the host of DevStories podcast. He can be reached via email and Twitter.