guides
Guide

How to Implement Magic Auth in a Next.js App

Magic Staff · May 16, 2023

In this guide, we're going to take a look at how to integrate Magic Auth with Next.js, utilizing our admin SDK and Next's API routes for our validation. We'll also look at how to use the React Context hook to ensure a fluid user experience for our app.

Let's start by forking and cloning down this starter code. This repo was created by using npx create-next-app and has all the starter files you'll need, along with some added stylings. 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 tutorial, here is what we want our user flow to look like:

  1. A user visits our app, enters their email address, and clicks the log in button.
  2. They copy the one-time passcorde sent to their email and use it to authenticate themselves on our site.
  3. Our app stores their metadata and redirects them to the dashboard page.
  4. The user is able to view their account.

You can view the completed code here.

#Getting started

Once you have forked and cloned the starter repo, you'll want to take the following steps:

Install the dependencies

Bash
01npm install
02# or
03yarn install

Run the development server

Bash
01npm run dev
02# or
03yarn dev

Open http://localhost:3000 with your browser to see the result.

#Creating the first pages

First, we're going to get our context set up so that our user information is available throughout our app. Navigate to lib/UserContext.js and add the following code to create our Context object.

Javascript
01// lib/UserContext.js
02import { createContext } from 'react';
03
04export const UserContext = createContext(null);

Then navigate to pages/_app.js so that we can wrap our app in the Provider component and initialize our user state. This will make it so all of our pages have access to user and setUser.

Javascript
01// pages/_app.js
02import '@/styles/globals.css';
03import { useState } from 'react';
04import { UserContext } from '@/lib/UserContext';
05
06export default function App({ Component, pageProps }) {
07  const [user, setUser] = useState();
08
09  return (
10    <UserContext.Provider value={[user, setUser]}>
11      <Component {...pageProps} />
12    </UserContext.Provider>
13  );
14}

Next, we're going to create the scaffolding on the client-side for our login and dashboard pages. Open up pages/login.js and update it with the following.

Javascript
01// pages/login.js
02import { useContext, useState } from 'react';
03import { UserContext } from '@/lib/UserContext';
04
05export default function Login() {
06  // Allows us to access the user state and update it within this page
07  const [user, setUser] = useContext(UserContext);
08  // Our email state that we'll use for our login form
09  const [email, setEmail] = useState('');
10
11  const handleLogin = async (e) => {
12    e.preventDefault();
13
14    // We'll fill in the rest of this later
15  };
16
17  return (
18    <form onSubmit={handleLogin}>
19      <label htmlFor="email">Email</label>
20      <input
21        name="email"
22        type="email"
23        value={email}
24        onChange={(e) => setEmail(e.target.value)}
25      />
26      <button type="submit">Log in</button>
27    </form>
28  );
29}

Then open up pages/dashboard.js. This will be the page our user sees once they've signed in through Magic. Update it with the code below.

Javascript
01// pages/dashboard.js
02import { useContext } from 'react';
03import { UserContext } from '@/lib/UserContext';
04
05export default function Dashboard() {
06  const [user, setUser] = useContext(UserContext);
07
08  const logout = () => {
09    // We'll fill this out later
10  };
11
12  return (
13    <>
14      {/* We check here to make sure user exists before attempting to access its data */}
15      {user?.issuer && (
16        <>
17          <h1>Dashboard</h1>
18          <h2>Email</h2>
19          <p>{user.email}</p>
20          <h2>Wallet Address</h2>
21          <p>{user.publicAddress}</p>
22          <button onClick={logout}>Logout</button>
23        </>
24      )}
25    </>
26  );
27}

Lastly, we're going to add in loading functionality to our app so that our users don't see a blank page while it loads. Replace pages/index.js with the following.

Javascript
01// pages/index.js
02import { useContext } from 'react';
03import { UserContext } from '@/lib/UserContext';
04
05export default function Home() {
06  // Allow this component to access our user state
07  const [user] = useContext(UserContext);
08
09  return (
10    <div>
11      {/* Check to see if we are in a loading state and display a message if true */}
12      {user?.loading && <p>Loading...</p>}
13    </div>
14  );
15}

#Set up Magic integration

Our first step in integrating Magic is by creating an account, which you can do by heading over to magic.link. You'll then create a new Magic Auth app, which will provide you with your API keys.

In your codebase, rename .env.local.example to .env.local and replace the placeholders with your publishable API key and secret key.

Javascript
01// .env.local
02NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY = 'YOUR_PUBLISHABLE_API_KEY';
03MAGIC_SECRET_KEY = 'YOUR_SECRET_KEY';

We are then going to create a helper function that we will use to create our Magic instances. This instance will allow us access to all of Magic's methods and connect us to the Ethereum Network (Magic allows us to connect to 20+ blockchains). Navigate to lib/magic.js and add the following.

Javascript
01// lib/magic.js
02import { Magic } from 'magic-sdk';
03
04const createMagic = (key) => {
05  // We make sure that the window object is available
06  // Then we create a new instance of Magic using a publishable key
07  return typeof window !== 'undefined' && new Magic(key);
08};
09
10// Pass in your publishable key from your .env file
11export const magic = createMagic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY);

We are now able to easily create new instances of Magic wherever needed, allowing us to call this function rather than having to repeatedly copy/paste this code.

#Authenticate with Magic

Magic is built using principles of distributed security. We 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 our security documentation). Not even Magic employees have access to the HSM. We've locked everyone out of ever getting access to the keys stored there.

Since Magic runs in this distributed manner, our 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.

To start, let's take care of our login API endpoint, which we'll be using shortly in our login page. This will be how we validate the user's DID token and issue an authentication token in return. We'll be using Magic's server-side SDK for this.

Javascript
01// pages/api/login.js
02import { Magic } from '@magic-sdk/admin';
03
04// Create an instance of magic admin using our secret key (not our publishable key)
05let mAdmin = new Magic(process.env.MAGIC_SECRET_KEY);
06
07export default async function login(req, res) {
08  try {
09    // Grab the DID token from our headers and parse it
10    const didToken = mAdmin.utils.parseAuthorizationHeader(
11      req.headers.authorization,
12    );
13    // Validate the token and send back a successful response
14    await mAdmin.token.validate(didToken);
15    res.status(200).json({ authenticated: true });
16  } catch (error) {
17    res.status(500).json({ error: error.message });
18  }
19}

Now let's go back and finish our login page. We're going to be filling out the handleLogin function by utilizing our new endpoint that we created in pages/api/login.js. We'll also add a useEffect hook to check if a user is already logged in, and if so, route them to the dashboard.

Within our handleLogin function, loginWithEmailOTP will send an email to the user and they will follow a secure authentication process outside of our application.

Once the user has successfully authenticated, Magic will return a DID which we can use as a token in our application. This token will then be sent to our server-side implementation of Magic's SDK where we can validate it and issue an authorization token so that the user has access to our app. Upon receiving a successful response, we retrieve the user's metadata, store it in our user context, and route them to the dashboard.

Javascript
01// pages/login.js
02import { useContext, useState, useEffect } from 'react';
03import { UserContext } from '@/lib/UserContext';
04import { useRouter } from 'next/router';
05import { magic } from '@/lib/magic';
06
07export default function Login() {
08  const [user, setUser] = useContext(UserContext);
09  const [email, setEmail] = useState('');
10  // Create our router
11  const router = useRouter();
12
13  // Make sure to add useEffect to your imports at the top
14  useEffect(() => {
15    // Check for an issuer on our user object. If it exists, route them to the dashboard.
16    user?.issuer && router.push('/dashboard');
17  }, [user]);
18
19  const handleLogin = async (e) => {
20    e.preventDefault();
21
22    // Log in using our email with Magic and store the returned DID token in a variable
23    try {
24      const didToken = await magic.auth.loginWithEmailOTP({
25        email,
26      });
27
28      // Send this token to our validation endpoint
29      const res = await fetch('/api/login', {
30        method: 'POST',
31        headers: {
32          'Content-type': 'application/json',
33          Authorization: `Bearer ${didToken}`,
34        },
35      });
36
37      // If successful, update our user state with their metadata and route to the dashboard
38      if (res.ok) {
39        const userMetadata = await magic.user.getMetadata();
40        setUser(userMetadata);
41        router.push('/dashboard');
42      }
43    } catch (error) {
44      console.error(error);
45    }
46  };
47
48  return (
49    <form onSubmit={handleLogin}>
50      <label htmlFor="email">Email</label>
51      <input
52        name="email"
53        type="email"
54        value={email}
55        onChange={(e) => setEmail(e.target.value)}
56      />
57      <button type="submit">Log in</button>
58    </form>
59  );
60}

#Persisting authorization state and logging out

By default, Magic allows users to remain authenticated for up to 7 days, so long as they don't logout or clear their browser data (this can be extended up to 90 days with Magic Auth Plus). In our case, once a user has been authenticated, we don't want them to have to repeat this login process every time they leave our site and come back. To accomplish this, we are going to utilize another useEffect along with Magic's isLoggedIn method, which will return true if they have an authenticated token stored in cookies, and false if not. Open pages/_app.js and update it with the following.

Javascript
01// pages/_app.js
02import '@/styles/globals.css';
03import { useState, useEffect } from 'react';
04import { UserContext } from '@/lib/UserContext';
05import { useRouter } from 'next/router';
06import { magic } from '@/lib/magic';
07
08export default function App({ Component, pageProps }) {
09  const [user, setUser] = useState();
10  // Create our router
11  const router = useRouter();
12
13  useEffect(() => {
14    // Set loading to true to display our loading message within pages/index.js
15    setUser({ loading: true });
16    // Check if the user is authenticated already
17    magic.user.isLoggedIn().then((isLoggedIn) => {
18      if (isLoggedIn) {
19        // Pull their metadata, update our state, and route to dashboard
20        magic.user.getMetadata().then((userData) => setUser(userData));
21        router.push('/dashboard');
22      } else {
23        // If false, route them to the login page and reset the user state
24        router.push('/login');
25        setUser({ user: null });
26      }
27    });
28    // Add an empty dependency array so the useEffect only runs once upon page load
29  }, []);
30
31  return (
32    <UserContext.Provider value={[user, setUser]}>
33      <Component {...pageProps} />
34    </UserContext.Provider>
35  );
36}

Now that we're able to authenticate our user and persist this state, we're going to give them the ability to log out. Open pages/dashboard.js and update it with the following.

Javascript
01// pages/dashboard.js
02import { useContext } from 'react';
03import { UserContext } from '@/lib/UserContext';
04import { magic } from '@/lib/magic';
05import { useRouter } from 'next/router';
06
07export default function Dashboard() {
08  const [user, setUser] = useContext(UserContext);
09  // Create our router
10  const router = useRouter();
11
12  const logout = () => {
13    // Call Magic's logout method, reset the user state, and route to the login page
14    magic.user.logout().then(() => {
15      setUser({ user: null });
16      router.push('/login');
17    });
18  };
19
20  return (
21    <>
22      {user?.issuer && (
23        <>
24          <h1>Dashboard</h1>
25          <h2>Email</h2>
26          <p>{user.email}</p>
27          <h2>Wallet Address</h2>
28          <p>{user.publicAddress}</p>
29          <button onClick={logout}>Logout</button>
30        </>
31      )}
32    </>
33  );
34}

That's it! Our app is now able to create new wallets and securely authenticate users, all without passwords.

#Next steps

Head over to our docs and experiment with integrating other features of the Magic SDK. You could try adding a login via SMS or maybe some social logins. Also, by incorporating either the Ethers.js or Web3.js libraries, you can add in the functionality for your users to sign and send transactions.

Let's make some magic!