Skip to content
I spend a lot of my time building web applications. I want to get as many of my ideas out into the world as possible and nothing has made that simpler than Vercel. Their technologies — Next.js and their cloud platform — have helped me prototype and build full-fledged applications in days!
But even with the wealth of tools they produce, auth was always a barrier for me. No matter how quickly I could build everything else – I always struggled with auth.
Until someone showed me Magic. Magic is a new product that makes it simple to add email link login, like the ones used by Slack or Medium, to your application. It brings an amazing developer experience, minimal work to integrate and world class security. It may just be the product I have been looking for to solve my authentication issues.
This article will dive into how to integrate Magic into your Next.js app by walking you through how I wrote Boost, a real world application that uses this approach. Boost is on a cutting edge Jamstack, written in Next.js, hosted on Vercel and using Magic for authentication. Check it out and see how it works. You can even launch your own Boost clone with one-click deploy on Vercel!

Getting started

We're going to cover how to integrate and allow Magic to work its... magic. We'll also look at how to use cookies, SWR and a little trick to ensure a fluid user experience for our app.
Let's start with a fresh Next.js project by using npx create-next-app. You'll want to be fairly familiar with how Next.js works before going any further (if not, check out their tutorial). As we work through this high level tutorial, here is what we want our user flow to look like:
  1. A user visits our app and clicks the login button.
  2. They fill out the form with their email.
  3. Magic authenticates them.
  4. Issue some cookies and redirect them to the dashboard page.

Creating the first pages

First we'll replace the contents of the index route to add a link to login. Since Next.js uses file system routing to handle pages this is all the code we need to create a page.
// pages/index.js
import Link from 'next/link'

export default function Home() {
  return <Link href="/login"><a>Login</a></Link>
}
Add the home for our users once they authenticate. We'll use the /dashboard route.
// pages/dashboard.js

export default function Dashboard() {
  return <h1>Dashboard</h1>
}
Now let's add a simple login page. Here is our pages/login.js file before we add any Magic code.
// pages/login.js

import { useRouter } from 'next/router'

export default function Login() {
  const router = useRouter()
  const handleSubmit = async (event) => {
    event.preventDefault()

    const { elements } = event.target

    // Add the Magic code here

    // Once we have the token from magic,
    // update our own database
    
    // const authRequest = await fetch()

    // if (authRequest.ok) {
      // We successfully logged in, our API
      // set authorization cookies and now we
      // can redirect to the dashboard!
      // router.push('/dashboard')
    // } else { /* handle errors */ }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input name="email" type="email" />
      <button>Log in</button>
    </form>
  )
}
Now that all of our pages are set up, we can get into adding authentication.

Set up Magic integration

Our first step in adding authentication is to integrate Magic by creating an account, which you can do by heading over to magic.link.

Getting the keys from Magic.link.

Grab both the publishable and secret keys and place them in the environment (.env.local) file like below. Also, we’ll add ENCRYPTION_SECRET, which will be used for encryption. You should create your own secret.
# .env.local

MAGIC_SECRET_KEY=sk_test_****************

# We’ll use the NEXT_PUBLIC_ prefix
# to expose this variable to the browser.
# See: https://nextjs.org/docs/basic-features/environment-variables
NEXT_PUBLIC_MAGIC_PUB_KEY=pk_test_****************

ENCRYPTION_SECRET=you-should-create-your-own-secret-to-use-for-encryption
Note: You must restart the development server after adding .env.local.
Once we have the keys, we'll need to use the Magic library to handle authenticating our users, so let's install that.
# Use npm install if you’re not using yarn
yarn add magic-sdk

Authenticate with Magic

Once we have the SDK installed we will need to import it and call Magic.loginWithMagicLink when the form is submitted. loginWithMagicLink will send an email to the authenticating user and they will follow a secure authentication process outside of our application.
Once the user has successfully authenticated with Magic, they'll be instructed to return to our app. At that point Magic will return a decentralized identifier or DID which we can use as a token in our application. To get that process started, we'll call Magic inside handleSubmit.
// pages/login.js
import { useRouter } from 'next/router'
import { Magic } from 'magic-sdk'

export default function Login() {
  const router = useRouter()
  const handleSubmit = async (event) => {
    event.preventDefault()
  
    const { elements } = event.target
  
    // the Magic code
    const did = await new Magic(process.env.NEXT_PUBLIC_MAGIC_PUB_KEY)
      .auth
      .loginWithMagicLink({ email: elements.email.value })
  
    // Once we have the token from magic,
    // update our own database
    // const authRequest = await fetch()
  
    // if (authRequest.ok) {
      // We successfully logged in, our API
      // set authorization cookies and now we
      // can redirect to the dashboard!
      // router.push('/dashboard')
    // } else { /* handle errors */ }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor='email'>Email</label>
      <input name='email' type='email' />
      <button>Log in</button>
    </form>
  )
}
Magic is built using principles of distributed security. They run a secure in-house operation that delegates most of the security storage to Amazon's Hardware Security Module or HSM (you can learn more about HSMs and how Magic uses them in their security documentation). Not even Magic employees have access to the HSM. They've locked everyone out of ever getting access to the keys stored there.
Since Magic runs in this distributed manner, their authentication returns a decentralized identifier. This identifier can be exchanged with Magic for information about the user. Once we have that DID we know that Magic has successfully authenticated that user, and our app can take over.

Issue an authorization token

Now that Magic has cleared the user and given us a DID to work with, we want to make use of it. With the DID we can create a user in our own database and issue an authorization token so that they have access to our restricted APIs.
To do that, we are going to create an API route called login.js. (For the purposes of this article we aren't going to create any users but you can check out the code inside Boost or the API to see how that would work.) We're going to start with a skeleton node.js request handler in the pages/api/login.js route.
// pages/api/login.js

export default async (req, res) => {
  if (req.method !== 'POST') return res.status(405).end()

  // exchange the DID from Magic for some user data
  // TODO

  // Author a couple of cookies to persist a users session
  // TODO

  res.end()
}
To get our login form submission to talk to the API route we just created, we'll add a fetch request. That request will send the decentralized id in the Authorization header so we can access it on the server.
// pages/login.js

const handleSubmit = async (event) => {
  event.preventDefault()

  const { elements } = event.target;

  // the magic code
  const did = await new Magic(process.env.NEXT_PUBLIC_MAGIC_PUB_KEY)
    .auth
    .loginWithMagicLink({ email: elements.email.value })

  // Once we have the did from magic, login with our own API
  const authRequest = await fetch('/api/login', {
    method: 'POST',
    headers: { Authorization: `Bearer ${did}` }
  })

  if (authRequest.ok) {
    // We successfully logged in, our API
    // set authorization cookies and now we
    // can redirect to the dashboard!
    router.push('/dashboard')
  } else { /* handle errors */ }
}

Persisting authorization state

Once our front and back end are talking to each we can go ahead and persist our login state with a cookie - or two. To get the user data we're going to exchange that DID token with Magic. Once we have the user we'll store that in our cookie - as if it was an object from our own database.
To do so, we will have to add Magic's node package.
yarn add @magic-sdk/admin
Magic's admin API will give us the ability to trade that DID for user information. In pages/api/login.js we'll add a few lines to integrate the Magic admin package.
// pages/api/login

import {Magic} from '@magic-sdk/admin'

let magic = new Magic(process.env.MAGIC_SECRET_KEY)

export default async (req, res) => {
  if (req.method !== 'POST') return res.status(405).end()

  // exchange the DID from Magic for some user data
  const did = magic.utils.parseAuthorizationHeader(req.headers.authorization)
  const user = await magic.users.getMetadataByToken(did)

  // Author a couple of cookies to persist a users session
  // TODO

  res.end()
}
Take a minute to pause and appreciate that integrating world class authentication with Magic took three lines of code.
One on the client side:
await new Magic(process.env.NEXT_PUBLIC_MAGIC_PUB_KEY)
  .auth
  .loginWithMagicLink({ email: elements.email.value })
And two on the server side:
magic.utils.parseAuthorizationHeader(req.headers.authorization)
await magic.users.getMetadataByToken(did)
With those two lines you are getting the best of modern security. Magic is the culmination of years spent building and running a security product called Fortmatic. Thanks to the creators of Fortmatic, and now Magic, you get it all for two lines of code. If you want to read more about the security of Magic you can take a look at their awesome documentation and FAQs.
With authentication done, let's persist the user state in some cookies. Here is a prefab utility (or service) we can use to issue our cookies. In this service we will be creating two cookies:
  1. One (api_token) to our access token that will allow our authenticated users to make requests to protected resources in our backend. We will store this cookie as httpOnly and secure which will force this cookie to be sent in secure connection request only.
  2. The other (authed) to assist our client side navigation and fluid user experience. This cookie will need to be read with JavaScript, so although it will be marked as secure for HTTPS connections, we won't enable httpOnly.
Once we have created those cookies, we'll attach them to the browser using the request header Set-Cookie.
// lib/cookie.js

import { serialize } from "cookie"

const TOKEN_NAME = "api_token"
const MAX_AGE = 60 * 60 * 8

function createCookie(name, data, options = {}) {
  return serialize(name, data, {
    maxAge: MAX_AGE,
    expires: new Date(Date.now() + MAX_AGE * 1000),
    secure: process.env.NODE_ENV === "production",
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    ...options,
  })
}

function setTokenCookie(res, token) {
  res.setHeader("Set-Cookie", [
    createCookie(TOKEN_NAME, token),
    createCookie("authed", true, { httpOnly: false }),
  ])
}

function getAuthToken(cookies) {
  return cookies[TOKEN_NAME]
}

export default { setTokenCookie, getAuthToken }
To create the token we want to store in our cookie, we will use a library called Iron to encrypt and decrypt our user data. First, let’s install Iron:
yarn add @hapi/iron
Then make some modifications the the pages/api/login.js.
// pages/api/login.js

import {Magic} from '@magic-sdk/admin'
import Iron from '@hapi/iron'
import CookieService from '../../lib/cookie'

export default async (req, res) => {
  if (req.method !== 'POST') return res.status(405).end()

  // exchange the did from Magic for some user data
  const did = req.headers.authorization.split('Bearer').pop().trim()
  const user = await new Magic(process.env.MAGIC_SECRET_KEY).users.getMetadataByToken(did)

  // Author a couple of cookies to persist a user's session
  const token = await Iron.seal(user, process.env.ENCRYPTION_SECRET, Iron.defaults)
  CookieService.setTokenCookie(res, token)

  res.end()
}
There we have it. The user is now authorized, and cookies are set to keep authorized state on the client. To make authenticated API calls, like getting information on the currently authorized user, we simply send a fetch request and our newly created cookie will be sent along with it.

Using our hooks and cookies to get user data

To use our cookie and get the user data we'll create a route called pages/api/user.js. In that route we can decrypt our token, and send back the data associated with that user. This isn't a complete implementation but it should give you an idea of what you need to do.
// pages/api/user.js

import Iron from '@hapi/iron'
import CookieService from '../../lib/cookie'

export default async (req, res) => {
  let user;
  try {
    user = await Iron.unseal(CookieService.getAuthToken(req.cookies), process.env.ENCRYPTION_SECRET, Iron.defaults)
  } catch (error) {
    res.status(401).end()
  }

  // now we have access to the data inside of user
  // and we could make database calls or just send back what we have
  // in the token.

  res.json(user)
}
On the frontend we will want the current authorized user to be easily accessible. For that we can create a React hook using another amazing Vercel package - SWR. First, let’s install swr:
yarn add swr
Here is what that hook might will look like:
// hooks/useAuth.js
import useSWR from "swr";

function fetcher(route) {
  /* our token cookie gets sent with this request */
  return fetch(route)
    .then((r) => r.ok && r.json())
    .then((user) => user || null);
}

export default function useAuth() {
  const { data: user, error, mutate } = useSWR("/api/user", fetcher);
  const loading = user === undefined;

  return {
    user,
    loading,
    error,
  };
}
This useAuth hook will allow us to keep the latest info about the authorized user consistent across tabs and pages. If you want to know how that works you can read more about SWR here. Here is how we might use it to display the user email on our /dashboard.js page.
// pages/dashboard.js

import useAuth from "../hooks/useAuth";

export default function Dashboard() {
  const { user, loading } = useAuth();

  return (
    <>
      <h1>Dashboard</h1>
      {loading ? "Loading..." : user.email}
    </>
  );
}

Handling unwanted page transitions

The last piece to our auth puzzle is some smooth handling of routes for authenticated users. For example, the home for an authenticated user might be the /dashboard page. For anyone else it is simply the index. How do we handle redirecting users to the best page without flashing multiple interfaces?
This is where that second cookie we set comes in. We'll inject a script tag with some redirect logic in the <head/> of our application. Handling redirections is now checking if that authed cookie is set and redirect to the appropriate page, something like so:
if (document.cookie && document.cookie.includes('authed')) {
  window.location.href = "/dashboard"
}
Now we can add that logic to our / route and authenticated users will get automatically redirected to the dashboard. Here is what our pages/index.js might look like:
// pages/index.js

import Head from "next/head";

export default function Home() {
  return (
    <>
      <Head>
        <script dangerouslySetInnerHTML={{ __html: `
          if (document.cookie && document.cookie.includes('authed')) {
            window.location.href = "/dashboard"
          }
        ` }} />
      </Head>
      <Link href="/login"><a>Login</a></Link>
    </>
  );
}

Conclusion

There we have it, a bunch of the best ingredients for creating the perfect auth recipe. Magic's simple and secure authentication and Vercel's beautiful development tools. Now whenever I build apps, Magic will be my go to for authentication with Next.js.
Integrating authentication into Next.js applications has never been simpler. We're able to take advantage of years of security work and knowledge and plug it into our app with two lines of code. We are living in a time of simplicity in Jamstack development, and Magic is becoming a key component of that. Simple to integrate, simple for users and deployed on one of the simplest platforms ever built - Vercel.