Back to tutorials

Magic Link Authentication in Next.js with NextAuth and Fauna

Grégory D'Angelo

Grégory D'Angelo

Nov 29, 2021 · 22 min read

Magic Link Authentication in Next.js with NextAuth and Fauna

Overview

In this tutorial, we are going to explore using NextAuth and Fauna to set up passwordless authentication in a Next.js application. Each user will get a smooth and secure login experience using mailed magic links. In other words, users will be able to login into the application only using their email address (no password needed).

The final version of the project's code can be found on Github. You can use it as a starting point for any Next.js app that requires passwordless authentication.

You can also preview the example live here.

This tutorial will cover:

  • Configuring Next.js, NextAuth, and Fauna to work together seamlessly
  • Using Next.js dynamic API routes to handle authentication requests
  • Using Fauna and the Fauna Adapter for next-auth to persist users, email sign in tokens, and sessions
  • Creating custom login and confirmation pages with React + Tailwind CSS
  • Customizing the sign-in email and sending a welcome email to new users

There is a lot to cover, so let's get started!

Prerequisites

You will need Node.js version 12.13 or later installed to follow along.

In addition, make sure to create an account on Fauna.com before getting started. As of this writing, it is free and doesn't require a credit card.

Finally, you'll need an email server up and running to send the verification and welcome emails to your users. So make sure to have your email server settings (host, port, user, password) in hand as well.

Tasks

Create a Next.js app

The first thing we need to do is create a new Next.js application. The simplest way to do it is by using the tool called create-next-app, which bootstraps a Next.js app for you without the hassle of configuring everything yourself.

So, open your terminal, and run the following command:

npx create-next-app magic-next-auth

Note that magic-next-auth is the name of the project's directory I've chosen. Of course, you can use any other name you'd like.

You can now cd into your project directory:

cd magic-next-auth/

And run the development server with the following command:

npm run dev

If everything is working fine, your Next.js development server should be running on port 3000. Open http://localhost:3000/ from your browser to check this out.

Welcome to Next.js
Tasks

Install and configure Tailwind CSS

As we will create custom sign-in and confirmation pages (more on this later), we'll need a way of styling those pages. To do that, we'll use the great Tailwind CSS framework to save us some time and energy.

So, start by installing Tailwind and the following dependencies using npm:

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest

Note that here, in addition to Tailwind, we are installing postcss and autoprefixer.

PostCSS is a well-known CSS preprocessor that uses JS plugins for transforming styles. In our case, we'll configure Tailwind as a PostCSS plugin to generate the corresponding CSS from the utility classes inside our markup.

And since Tailwind does not automatically add vendor prefixes to the CSS it generates, we are also installing Autoprefixer to handle this for us. It is one of the most popular PostCSS plugins.

Once you have everything installed, generate your tailwind.config.js and postcss.config.js files with:

npx tailwindcss init -p

This will create a minimal Tailwind config file at the root of your project:

tailwind.config.js
module.exports = { purge: [], darkMode: false, // or 'media' or 'class' theme: { extend: {}, }, variants: { extend: {}, }, plugins: [], };

It will also create the PostCSS config file already configured with tailwindcss and autoprefixer as plugins:

postcss.config.js
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, };

Next, enable Tailwind Just-in-Time mode and configure the purge option from your tailwind.config.js file:

tailwind.config.js
module.exports = { mode: 'jit', purge: ['./pages/**/*.{js,ts,jsx,tsx}'], // ... }

Finally, make sure to include the Tailwind CSS directives in your CSS. So, open the ./styles/globals.css file and replace the original file content with:

styles/globals.css
@tailwind base; @tailwind components; @tailwind utilities;

You can also delete any other CSS file that Next.js generated for you by default, such as the styles/Home.module.css file, for instance. Make sure to remove any dependencies for this file in your project, like in the pages/index.js.

Alright! We are done with installing and configuring Tailwind CSS in our Next.js application.

Tasks

Add authentication with NextAuth + Fauna

1. Set up NextAuth

To add authentication to our Next.js app, we will use NextAuth.js, a full-featured authentication system with built-in providers such as Google, Facebook, or Github. It also supports JWT, email/password, and magic links authentication.

So, first, install next-auth using npm.

npm install --save next-auth

Next, from within your _app.js file, import the Provider from next-auth/react, wrap everything inside this provider, and set the session prop using pageProps.session to allow the session state to be shared between pages.

pages/_app.js
import { SessionProvider as AuthProvider } from 'next-auth/react'; // ... function MyApp{ Component, pageProps: { session, ...pageProps } }) { return ( <AuthProvider session={session}> <Component {...pageProps} /> </AuthProvider> ); } export default MyApp;

Then, create a new Next.js dynamic API route by creating a file in pages/api/auth named [...nextauth].js.

Since next-auth.js v4, you also need to install nodemailer to send emails, as it is no longer included as a dependency by default. So, make sure to install it with the following command:

npm i nodemailer

Then inside the [...nextauth].js file, import NextAuth, the EmailProvider from next-auth/providers/email, and nodemailer.

pages/api/auth/[...nextauth].js
import NextAuth from 'next-auth'; import EmailProvider from 'next-auth/providers/email'; import nodemailer from 'nodemailer';

Next, call and export as default NextAuth to automatically create and handle the API routes for authentication.

pages/api/auth/[...nextauth].js
export default NextAuth();

From within NextAuth, you can then pass an object to configure one or more authentication providers. Here, we are just going to use EmailProvider which uses email to send "magic links" to sign in users seamlessly with no password.

For a full list of all the supported authentication providers, check out the official documentation.

pages/api/auth/[...nextauth].js
export default NextAuth({ 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, maxAge: 10 * 60, // Magic links are valid for 10 min only }), ], });

As you can see, I've set the magic link in emails to only be valid for the next 10 minutes. You can use any other value (in seconds) you'd like. And if you don't set a value, the default is one day.

Also, I've used environment variables to configure the email provider. So, create a new file named .env.local at the root of your project's directory and add the following variables:

.env.local
EMAIL_SERVER_HOST=smtp.example.com.com EMAIL_SERVER_PORT=465 EMAIL_SERVER_USER=<YOUR_SMTP_USER> EMAIL_SERVER_PASSWORD=<YOUR_SMTP_PASSWORD> EMAIL_FROM=no-reply@example.com

Make sure to replace the values with your own from your email server settings.

You'll also need to restart your development server to load those new environment variables.

Tasks

2. Set up Fauna

The NextAuth email provider requires a database to work in order to persist users, email sign in tokens and sessions. So, let's create a new Fauna database and use it with NextAuth to store those information.

After creating an account on Fauna, go to your dashboard, and click Create database.

Set the database name, select the region group, and leave the checkbox "Use demo data" unchecked as we don't want to populate our database with demo data.

Click "Create".

Fauna - Create database

Then, from within your database's page, click on the "Security" tab, and click "New key" to create a new secret key you would use to get access to the Fauna API.

Fauna - Create secret key

From here, select your newly created database, set the role to "Server", and click "Save".

Fauna - Create secret key

You should now be presented with your key's secret. Make sure you copy this key because it won't be displayed again, as stated by the message on your screen.

Fauna - Create secret key

Now that you have your key, create a new environment variable FAUNA_SECRET_KEY inside your .env.local file and restart your development server.

.env.local
FAUNA_SECRET_KEY=<YOUR_SECRET_KEY>

Once we have our Fauna database and secret key, we can link it to NextAuth. To do so, we can use a NextAuth adapter to connect NextAuth to our Fauna database.

NextAuth provides this adapter out-of-the-box, so we don't need to create all the plumbing ourselves.

So, let's install the required dependencies using npm:

npm install faunadb @next-auth/fauna-adapter@next

Then, open the pages/api/[...nextauth].js file and import faunadb and @next-auth/fauna-adapter.

pages/api/[...nextauth].js
import { Client as FaunaClient } from 'faunadb'; import { FaunaAdapter } from '@next-auth/fauna-adapter'; // ...

At the top of your file, instantiate a Fauna database's client by passing in the Fauna secret key from the environment variables.

pages/api/[...nextauth].js
const client = new FaunaClient({ secret: process.env.FAUNA_SECRET_KEY, });

And add this adapter to your NextAuth configuration object.

pages/api/[...nextauth].js
// ... export default NextAuth({ providers: [...], adapter: FaunaAdapter(client), });

Finally, the last step is to set up all the collections and indexes to store and retrieve the users' information and accounts in our Fauna database.

So, run the following queries inside of the Shell tab in the Fauna dashboard:

CreateCollection({ name: 'accounts' }); CreateCollection({ name: 'sessions' }); CreateCollection({ name: 'users' }); CreateCollection({ name: 'verification_tokens' });
CreateIndex({ name: 'account_by_provider_and_provider_account_id', source: Collection('accounts'), unique: true, terms: [ { field: ['data', 'provider'] }, { field: ['data', 'providerAccountId'] }, ], }); CreateIndex({ name: 'session_by_session_token', source: Collection('sessions'), unique: true, terms: [{ field: ['data', 'sessionToken'] }], }); CreateIndex({ name: 'user_by_email', source: Collection('users'), unique: true, terms: [{ field: ['data', 'email'] }], }); CreateIndex({ name: 'verification_token_by_identifier_and_token', source: Collection('verification_tokens'), unique: true, terms: [{ field: ['data', 'identifier'] }, { field: ['data', 'token'] }], });

You are now all set! Your Next.js app is connected to your Fauna database through the NextAuth adapter.

So now, let's try to login into our application.

To do so, visit http://localhost:3000/api/auth/signin, which is a default login page generated by NextAuth. Then enter your email address and click Sign in with email.

Default login page

You should then be redirected to http://localhost:3000/api/auth/verify-request?provider=email&type=email.

Verify request page

Next, check your inbox and click the magic link from the email you just received. You should be redirected back to the homepage of your application.

Default confirmation email

To ensure everything has successfully worked, you can check your Fauna database. A new document for both the sessions and the users collections has been created for the authenticated user.

Tasks

3. Check if someone is signed in

Now, let's design the homepage of our application to display information about the user who is signed in, and give them the ability to sign out from the app.

To interact with sessions from our React components/pages, NextAuth provides a custom hooks named useSession that gives us the current session, if any.

So, from the /pages/index.js file, import this hook from next-auth/react:

pages/index.js
import { useSession } from 'next-auth/react';

Then, at the top of your Home component, call the useSession hook to retrieve the current session:

pages/index.js
export default function Home() { const { data: session } = useSession(); // ... }

Next, inside your JSX, either render a Link to redirect the current visitor to the login page if there is no session or display the email address of the authenticated user in case there is a session.

pages/index.js
import Link from 'next/link'; import { LightningBoltIcon } from '@heroicons/react/outline'; import { useSession } from 'next-auth/react'; export default function Home() { const { data: session } = useSession(); return ( <div className="min-h-screen container mx-auto px-6 py-12 flex flex-col items-center justify-center"> <h1 className="inline-flex flex-col sm:flex-row items-center space-y-2 sm:space-y-0 sm:space-x-2"> <LightningBoltIcon className="shrink-0 w-14 h-14 sm:w-16 sm:h-16 text-blue-500" /> <span className="sm:h-16 text-4xl sm:text-6xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-blue-400 to-blue-700 text-center"> Magic NextAuth </span> </h1> <p className="mt-4 text-gray-500 text-xl sm:text-2xl text-center"> Magic Link Authentication in Next.js with NextAuth and Fauna </p> <div className="mt-8"> {session?.user ? ( <div className="text-lg flex flex-col space-y-1 bg-gray-200 rounded-lg px-6 py-3"> <p> Signed in as <strong>{session.user.email}</strong> </p> </div> ) : ( <Link href="/api/auth/signin"> <a className="px-6 py-3 rounded-md text-lg text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-opacity-50 transition"> Get started </a> </Link> )} </div> </div> ); }

Note that I've used the Tailwind utility classes to style the page. Feel free to tweak as you wish.

I'm also using an SVG icon from the @heroicons library for the logo. Make sure to install it with npm i @heroicons/react if you'd like to use those icons.

Finally, let's also add a button to let the user sign out from the application. To do so, we can use the signOut() method from NextAuth and call it once the user clicks Sign Out.

pages/index.js
import { signOut, useSession } from 'next-auth/react'; // ... export default function Home() { const { data: session } = useSession(); return ( <div> {/* ... */} <div> {session?.user ? ( <div> {/* ... */} <button onClick={signOut} className="font-semibold underline opacity-70 hover:opacity-100" > Sign Out </button> </div> ) : ( { /* ... */ } )} </div> </div> ); }
Default confirmation email

Alright! You should now be able to see the authenticated user's email address from the homepage if you are logged in already, and you should be able to sign out from the app. And when you are not authenticated, you should see a big blue button redirecting you to the default login page on click.

Tasks

Create a custom login page

Having default unbranded authentication pages is great when starting a new project. However, most of the time, we'd like to design our own pages to match our brand and business.

Fortunately, NextAuth let us define custom pages by using the pages option from within the NextAuth configuration object.

So, let's go ahead and do it.

pages/api/auth/[...nextauth].js
export default NextAuth({ pages: { signIn: '/auth/signin', signOut: '/', }, // ... });

Here, I've defined a custom path for the login page and used / for the sign out page so that when the user signs out, they are redirected back to the homepage of the application.

Let's keep going and create our custom login page in pages/auth/signin.js:

pages/auth/signin.js
import { useState } from 'react'; import { LightningBoltIcon } from '@heroicons/react/outline'; const SignIn = () => { const [email, setEmail] = useState(''); return ( <div className="min-h-screen flex flex-col items-center justify-center px-4 py-12"> <LightningBoltIcon className="shrink-0 w-14 h-14 sm:w-16 sm:h-16 text-blue-500" /> <h1 className="mt-2 text-2xl sm:text-4xl text-center font-bold"> Sign in to your account </h1> <form className="mt-8 rounded-lg shadow-md bg-white px-4 py-6 sm:px-8 sm:py-8 space-y-6 w-full max-w-md"> <div className="flex flex-col space-y-1"> <label htmlFor="email" className="text-gray-500 text-sm"> Email address </label> <input id="email" type="email" required value={email} onChange={e => setEmail(e.target.value)} placeholder="elon@spacex.com" className="py-2 px-4 w-full border rounded-md border-gray-300 focus:outline-none focus:ring-4 focus:ring-opacity-20 focus:border-blue-400 focus:ring-blue-400 transition disabled:opacity-50 disabled:cursor-not-allowed " /> </div> <button type="submit" className="px-6 py-2 rounded-md text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-opacity-50 w-full disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-500 transition" > Sign in </button> </form> </div> ); }; export default SignIn;

You should get this beautiful login page when visiting http://localhost:3000/auth/signin.

Custom login page made with React + Tailwind CSS

At this point, make sure to replace the link to the login page in the /pages/index.js from /api/auth/signin to /auth/signin.

Now that we have created our custom login page, let's implement the sign-in logic when the user clicks Sign in after entering their email address.

So, create a new function named handleSignIn inside your component, and call it from the onSubmit event of the form tag.

pages/auth/signin.js
const handleSignIn = async e => { e.preventDefault(); }; <form onSubmit={handleSignIn} .../>

Inside this function, call the signIn method from next-auth/react (don't forget to import it at the top):

pages/auth/signin.js
import { signIn } from 'next-auth/react'; // ... try { // Perform sign in const { error } = await signIn('email', { email, redirect: false, callbackUrl: `${window.location.origin}/auth/confirm-request`, }); // Something went wrong if (error) { throw new Error(error); } } catch(error) { // handle error here (eg. display message to user) }

Here, we set the sign-in method to email, and we pass an option object which contains:

  • the email address the user entered in the input field
  • the redirect option set to false as we want the user to stay on this page
  • and the callbackUrl set to ${window.location.origin}/auth/confirm-request which is the base URL of the magic link sent to the user. We will create this page below.

In addition to calling the signIn method and because we set the redirect option to false, let's display a dialog to the user once the sign-in email has been sent successfully.

For doing so, we first need to create the corresponding Modal component:

pages/auth/signin.js
import { createPortal } from 'react-dom'; const MagicLinkModal = ({ show = false, email = '' }) => { if (!show) return null; return createPortal( <div className="fixed inset-0 z-10 bg-white bg-opacity-90 backdrop-filter backdrop-blur-md backdrop-grayscale"> <div className="min-h-screen px-6 flex flex-col items-center justify-center animate-zoomIn"> <div className="flex flex-col items-center justify-center text-center max-w-sm"> <MailOpenIcon className="shrink-0 w-12 h-12 text-blue-500" /> <h3 className="mt-2 text-2xl font-semibold">Confirm your email</h3> <p className="mt-4 text-lg"> We emailed a magic link to <strong>{email}</strong>. Check your inbox and click the link in the email to login. </p> </div> </div> </div>, document.body ); };

Then, create a new state variable to show/hide this dialog.

pages/auth/signin.js
const [showModal, setShowModal] = useState(false);

Next, show the dialog after calling the signIn method only if there is no error.

pages/auth/signin.js
const handleSignIn = async e => { e.preventDefault(); try { // Perform sign in const { error } = await signIn('email', { email, redirect: false, callbackUrl: `${window.location.origin}/auth/confirm-request`, }); // Something went wrong if (error) { throw new Error(error); } setShowModal(true); } catch (error) { // ... } };

Finally, render the dialog component at the end of your JSX along with its props.

pages/auth/signin.js
return ( <> <div>{/* ... */}</div> <MagicLinkModal show={showModal} email={email} /> </> );
Dialog component
Tasks

Create a custom confirmation page

In addition to this custom sign-in page, let's create a custom confirmation page to let the user knows they have successfully logged into the application after clicking the magic link from the email.

So, create a new file named confirm-request.js inside the /pages/auth folder.

Inside this file, use the useSession hook to check if the user has been properly authenticated. If not, redirect them to the homepage using the useRouter hook from next/router.

Otherwise, display a message on the screen to let the user knows they have successfully logged in.

pages/auth/confirm-request.js
import Link from 'next/link'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { CheckCircleIcon } from '@heroicons/react/outline'; const ConfirmRequest = () => { const { data: session, status } = useSession(); const loading = status === 'loading'; const router = useRouter(); if (!loading && !session) { router.push('/auth/signin'); } return ( <div className="min-h-screen flex flex-col items-center justify-center text-center px-4 py-12 max-w-md mx-auto"> {loading ? ( <p>Loading...</p> ) : !session ? ( <p>Redirecting...</p> ) : ( <> <CheckCircleIcon className="w-14 h-14 sm:w-16 sm:h-16 text-blue-600 shrink-0" /> <h1 className="text-2xl sm:text-4xl font-bold mt-4"> You&apos;re logged in! </h1> <p className="text-lg sm:text-2xl mt-4"> Go back to your original tab. </p> <p className="text-normal sm:text-lg text-gray-500 mt-6"> You can close this window or click{' '} <Link href="/"> <a className="text-blue-500 hover:underline hover:text-blue-600"> this link </a> </Link>{' '} to go back to the homepage. </p> </> )} </div> ); }; export default ConfirmRequest;
Login success page

Customize the sign-in email and send a welcome email to new users

Well done! You now have implemented passwordless authentication into your application and created custom login and confirmation pages.

But, let's go a step further and also customize the verification email we send to the user, and send a welcome email to our new users.

First, install the handlebars package as we are going to need it for creating custom dynamic emails:

npm i handlebars

Then, inside the pages/api/auth/[...nextauth].js file, import handlebars at the top.

pages/api/auth/[...nextauth].js
import Handlebars from 'handlebars';

Create a reusable transporter object using your SMTP settings:

pages/api/auth/[...nextauth].js
const transporter = nodemailer.createTransport({ 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, }, secure: true, });

Edit the email provider configuration by removing all the SMTP settings and add the sendVerificationRequest option to it:

pages/api/auth/[...nextauth].js
export default NextAuth({ // ... providers: [ EmailProvider({ maxAge: 10 * 60, sendVerificationRequest, }), ], // ... });

And define the sendVerificationRequest function above:

pages/api/auth/[...nextauth].js
import { readFileSync } from 'fs'; import path from 'path'; // ... const emailsDir = path.resolve(process.cwd(), 'emails'); const sendVerificationRequest = ({ identifier, url }) => { const emailFile = readFileSync(path.join(emailsDir, 'confirm-email.html'), { encoding: 'utf8', }); const emailTemplate = Handlebars.compile(emailFile); transporter.sendMail({ from: `"⚡ Magic NextAuth" ${process.env.EMAIL_FROM}`, to: identifier, subject: 'Your sign-in link for Magic NextAuth', html: emailTemplate({ signin_url: url, email: identifier, }), }); }; // ...

This function is doing a few things.

  1. First, it is reading the confirmation email file and using Handlebars to create a template we can then use to inject dynamic values.

  2. Then, it uses the transporter object to send the email to the identifier, which is the user's email address we get from the function's arguments.

  3. Finally, the html option of the sendMail function receives the HTML of our email after injecting a few values, such as the magic link and the user's email, and compiling it with Handlebars.

If you don't have an HTML template for your email, you can use this one.

Here's the email you should now receive when trying to login into your application.

Custom confirmation email

Finally, four our last step in this tutorial, we will send a welcome email to our new users, using the NextAuth createUser event.

This event is an asynchronous function called by NextAuth when the adapter (Fauna) creates a new user on sign-in. It is very useful because it gives us the ability to run side effects when such an event occurs.

For the full list of the NextAuth events, check out the official documentation here.

So, as usual, edit the NextAuth configuration, and add the createUser event to it.

pages/api/auth/[...nextauth].js
export default NextAuth({ // ... events: { createUser: sendWelcomeEmail } )};

Them, create the sendWelcomeEmail function above it and send the welcome email to the user like with Nodemailer and Handlebars like we did before for the verification request.

pages/api/auth/[...nextauth].js
const sendWelcomeEmail = async ({ user }) => { const { email } = user; try { const emailFile = readFileSync(path.join(emailsDir, 'welcome.html'), { encoding: 'utf8', }); const emailTemplate = Handlebars.compile(emailFile); await transporter.sendMail({ from: `"⚡ Magic NextAuth" ${process.env.EMAIL_FROM}`, to: email, subject: 'Welcome to Magic NextAuth! 🎉', html: emailTemplate({ base_url: process.env.NEXTAUTH_URL, support_email: 'support@alterclass.io', }), }); } catch (error) { console.log(`❌ Unable to send welcome email to user (${email})`); } };

You can find the HTML template for the welcome email here.

Welcome email

Summary

Congratulations on reaching the end of this tutorial! You now have a Next.js application all set with passwordless authentication using NextAuth and Fauna DB for persisting user information. You even have learned how to create custom authentication pages using React and Tailwind CSS, and customize the emails you send to your users.

If you liked this tutorial, follow me on Twitter and subscribe to my YouTube channel.

And if you've got any questions, please ask them on Twitter!

Share

Guest Authors

Write for the AlterClass blog to empower the developer community and grow your brand.

Join us

Stay up to date

Subscribe to the newsletter to stay up to date with tutorials, courses and much more!