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:
- A SendGrid account - Sign up here to send up to 100 emails per day completely free of charge
- Node.js Installation
Setting up the project
Start by creating a new Next.js 13 project using the following command:
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.
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:
[email protected]/”
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:
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:
Type your email and click on the blue Login button:
You should see this message on the screen:
Check the inbox of your email and you should get a login link:
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:
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.