guides
Guide

Email OTP Wallets on EVM chains using Next.js

Magic Staff · January 4, 2024
Email OTP Wallets on EVM chains using Next.js

Magic is a developer SDK that integrates with your application to enable passwordless Web3 onboarding (no seed phrases) and authentication using magic links (similar to Slack and Medium).

Magic enables blazing-fast, hardware-secured, passwordless login, Web3 onboarding, and access to over 20 blockchains with a few lines of code — even if you have an existing auth solution.

This guide takes you step by step through integrating a Magic Wallet into a Next.js application using the Magic SDK and Web3.js. Check out our quickstart if you want to skip the in-depth walkthrough.

The sections below walk through setting up a Next.js application, installing and using the Magic SDK, and creating basic components for authentication and EVM wallet interactions. If you would like to add Magic to an existing project, simply skip the first step of creating a new application and dive right into integrating Magic.

The code examples in this guide assume a Next.js 14 project that leverages Tailwind CSS for component styling. However, you may use a different framework and choice of styling. Just be sure to adjust the code examples accordingly.

To see our final code, you can check out this Github Repository or tinker directly in the browser with the Codesandbox version.

#Getting Started

We'll begin by scaffolding a new application and installing relevant dependencies: Magic SDK and Web3.js.

#Create a New Next.js Application

note

If you want to add Magic to an existing project, please skip this step.

Open a shell and run the following command:

Bash
01npx create-next-app my-app --typescript

When prompted, select TypeScript, Tailwind CSS, /src directory, and App Router.

#Install Dependencies

Navigate to the project directory and install the Magic SDK and Web3.js as dependencies. You can do this with the following command:

NPM
Yarn
01npm install magic-sdk web3

#Set Up Global State

Next, we'll set up a global state for our application using the React Context API. This global state will allow you to share state and functionality throughout your application without having to pass props down through multiple layers of components.

Specifically, we'll create two contexts: UserContext and MagicProvider. The UserContext will simply store the authenticated user's wallet address. The MagicProvider will store a Magic reference we can use to access the Magic SDK modules and a Web3 reference we can use to interact with the blockchain.

#MagicProvider Context

Create a new file in the src/app/context directory named MagicProvider.tsx. Open MagicProvider.tsx and paste the following code:

Typescript
01// src/app/context/MagicProvider.tsx
02
03"use client"
04import { Magic } from "magic-sdk"
05import {
06  ReactNode,
07  createContext,
08  useContext,
09  useEffect,
10  useMemo,
11  useState,
12} from "react"
13const { Web3 } = require("web3")
14
15type MagicContextType = {
16  magic: Magic | null
17  web3: typeof Web3 | null
18}
19
20const MagicContext = createContext<MagicContextType>({
21  magic: null,
22  web3: null,
23})
24
25export const useMagic = () => useContext(MagicContext)
26
27const MagicProvider = ({ children }: { children: ReactNode }) => {
28  const [magic, setMagic] = useState<Magic | null>(null)
29  const [web3, setWeb3] = useState<typeof Web3 | null>(null)
30
31  useEffect(() => {
32    if (process.env.NEXT_PUBLIC_MAGIC_API_KEY) {
33      const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_API_KEY || "", {
34        network: {
35          rpcUrl: "<https://rpc2.sepolia.org/>",
36          chainId: 11155111,
37        },
38      })
39
40      setMagic(magic)
41      setWeb3(new Web3((magic as any).rpcProvider))
42    }
43  }, [])
44
45  const value = useMemo(() => {
46    return {
47      magic,
48      web3,
49    }
50  }, [magic, web3])
51
52  return <MagicContext.Provider value={value}>{children}</MagicContext.Provider>
53}
54
55export default MagicProvider

The above code defines MagicContext and exports a corresponding MagicProvider and useMagic hook. The MagicProvider initializes and surfaces both an instance of Magic and Web3. Subsequent sections will use the useMagichook to access both of these objects.

The Magic initialization requires an environment variable NEXT_PUBLIC_MAGIC_API_KEY. You should add this environment variable to your .env.local file with a valid Magic Publishable API Key. You can find this in your Magic Dashboard.

Additionally, the above code snippet initializes Magic with a public Sepolia Testnet URL. You can point the instance to a different chain by modifying the URL and Chain ID. Magic seamlessly supports over 25 different blockchains.

Finally, Web3 is initialized using the RPC provider from the newly initialized Magic instance. If you plan to use your own RPC provider, please follow the instructions to allowlist your node URL.

#UserContext

Next, create a new file in the src/app/context directory named UserContext.tsx. Open UserContext.tsx and paste the following code:

Typescript
01// src/app/context/UserContext.tsx
02
03"use client"
04import React, { createContext, useContext, useEffect, useState } from "react"
05import { useMagic } from "./MagicProvider"
06
07// Define the type for the user
08type User = {
09  address: string
10}
11
12// Define the type for the user context.
13type UserContextType = {
14  user: User | null
15  fetchUser: () => Promise<void>
16}
17
18// Create a context for user data.
19const UserContext = createContext<UserContextType>({
20  user: null,
21  fetchUser: async () => {},
22})
23
24// Custom hook for accessing user context data.
25export const useUser = () => useContext(UserContext)
26
27// Provider component that wraps parts of the app that need user context.
28export const UserProvider = ({ children }: { children: React.ReactNode }) => {
29  // Use the web3 context.
30  const { web3 } = useMagic()
31
32  // Initialize user state to hold user's account information.
33  const [address, setAddress] = useState<string | null>(null)
34
35  // Function to retrieve and set user's account.
36  const fetchUserAccount = async () => {
37    // Use Web3 to get user's accounts.
38    const accounts = await web3?.eth.getAccounts()
39
40    // Update the user state with the first account (if available), otherwise set to null.
41    setAddress(accounts ? accounts[0] : null)
42  }
43
44  // Run fetchUserAccount function whenever the web3 instance changes.
45  useEffect(() => {
46    fetchUserAccount()
47  }, [web3])
48
49  return (
50    <UserContext.Provider
51      value={{
52        user: address ? { address: address } : null,
53        fetchUser: fetchUserAccount,
54      }}
55    >
56      {children}
57    </UserContext.Provider>
58  )
59}

Because Magic integrates with existing libraries for blockchain interaction, like Web3.js, this code functions exactly as it would without Magic. It's simply a context that stores the connected account address as read from Web3. The fetchUserAccount function uses Web3 to retrieve the user's Ethereum accounts and saves the first account to the user state. This function is invoked whenever the Web3 instance changes and whenever our code calls the fetchUser function.

#Wrap App in Context Providers

Next, wrap the application with MagicProvider and UserProvider. This ensures that the contexts are accessible to all components within our application.

Open the src/app/layout.tsx file and update it with the following code:

Typescript
01// src/app/layout.tsx
02
03import type { Metadata } from "next"
04import "./globals.css"
05import MagicProvider from "./context/MagicProvider"
06import { UserProvider } from "./context/UserContext"
07
08export const metadata: Metadata = {
09  title: "Create Next App",
10  description: "Generated by create next app",
11}
12
13export default function RootLayout({
14  children,
15}: {
16  children: React.ReactNode
17}) {
18  return (
19    <html lang="en">
20      <body>
21        <MagicProvider>
22          <UserProvider>{children}</UserProvider>
23        </MagicProvider>
24      </body>
25    </html>
26  )
27}

The RootLayout is now nested inside the UserProvider and MagicProvider components. This provides all child components of RootLayout access to UserContext and MagicContext through the useUser and useMagic hooks, per React's Context API.

#UI Components

Next, we'll create six components for our application: ConnectButtonDisconnectButtonShowUIButtonSendTransactionSignMessage, and WalletDetail.

To begin, create a src/app/components directory to house the new components.

#ConnectButton Component

The ConnectButton component will trigger the authentication flow and connect to the authenticated user's wallet.

Create a new file in components named ConnectButton.tsx and paste the following code:

Typescript
01// src/app/components/ConnectButton.tsx
02
03import { useMagic } from "../context/MagicProvider"
04import { useUser } from "../context/UserContext"
05
06const ConnectButton = () => {
07  // Get the initializeWeb3 function from the Web3 context
08  const { magic } = useMagic()
09  const { fetchUser } = useUser()
10
11  // Define the event handler for the button click
12  const handleConnect = async () => {
13    try {
14      // Try to connect to the wallet using Magic's user interface
15      await magic?.wallet.connectWithUI()
16      await fetchUser()
17    } catch (error) {
18      // Log any errors that occur during the connection process
19      console.error("handleConnect:", error)
20    }
21  }
22
23  // Render the button component with the click event handler
24  return (
25    <button
26      type="button"
27      className="w-auto border border-white font-bold p-2 rounded-md"
28      onClick={handleConnect}
29    >
30      Connect
31    </button>
32  )
33}
34
35export default ConnectButton

The key functionality here is the call to magic?.wallet.connectWithUI(). This invocation returns a promise and will display Magic's Login UI for authentication. Magic will handle authentication using Email OTP with no additional code needed from your application. When the promise resolves, your code will handle the resolved value and re-fetch the user so your application can update accordingly.

#Disconnect Button

The DisconnectButton component will disconnect the user's wallet.

Create a new file in components named DisconnectButton.tsx and paste the following code:

Typescript
01// src/app/components/DisconnectButton.tsx
02
03import { useMagic } from "../context/MagicProvider"
04import { useState } from "react"
05import { useUser } from "../context/UserContext"
06
07const DisconnectButton = () => {
08  const [isLoading, setIsLoading] = useState(false)
09  // Get the initializeWeb3 function from the Web3 context
10  const { magic } = useMagic()
11  const { fetchUser } = useUser()
12
13  // Define the event handler for the button click
14  const handleDisconnect = async () => {
15    try {
16      setIsLoading(true)
17      // Try to disconnect the user's wallet using Magic's logout method
18      await magic?.user.logout()
19      await fetchUser()
20
21      setIsLoading(false)
22    } catch (error) {
23      // Log any errors that occur during the disconnection process
24      console.log("handleDisconnect:", error)
25    }
26  }
27
28  // Render the button component with the click event handler
29  return (
30    <button
31      type="button"
32      className="border border-white font-bold p-2 rounded-md"
33      onClick={handleDisconnect}
34    >
35      {isLoading ? "Disconnecting..." : "Disconnect"}
36    </button>
37  )
38}
39
40export default DisconnectButton

Again, the core functionality here is the call to magic?.user.logout(). This will log out the current user and disconnect their wallet. When the promise resolves, your application can re-fetch the user to update your UI accordingly.

#ShowUIButton Component

The ShowUIButton component will display the Magic Wallet interface.

Create a new file in components named ShowUIButton.tsx and paste the following code:

Typescript
01// src/app/components/ShowUIButton.tsx
02
03import { useMagic } from "../context/MagicProvider"
04
05const ShowUIButton = () => {
06  const { magic } = useMagic()
07
08  // Define the event handler for the button click
09  const handleShowUI = async () => {
10    try {
11      // Try to show the magic wallet user interface
12      await magic?.wallet.showUI()
13    } catch (error) {
14      // Log any errors that occur during the process
15      console.error("handleShowUI:", error)
16    }
17  }
18
19  return (
20    <button
21      className="w-auto border border-white font-bold p-2 rounded-md"
22      onClick={handleShowUI}
23    >
24      Show UI
25    </button>
26  )
27}
28
29export default ShowUIButton

The magic?.wallet.showUI() call will show a modal with the wallet interface.

#SendTransaction Component

The SendTransaction component allows the user to send a transaction using their wallet.

Since Magic integrates with your existing blockchain library, like Web3.js, the SendTransaction component can be built entirely without a reference to Magic. In other words, it can be built the same way it would be without Magic.

Create a new file in components named SendTransaction.tsx and paste the following code:

Typescript
01// src/app/components/SendTransaction.tsx
02
03import { useCallback, useState } from "react"
04import { useMagic } from "../context/MagicProvider"
05
06const SendTransaction = () => {
07  const { web3 } = useMagic()
08  const [toAddress, setToAddress] = useState("")
09  const [amount, setAmount] = useState("")
10  const [hash, setHash] = useState<string | null>(null)
11
12  const handleAddressInput = (e: React.ChangeEvent<HTMLInputElement>) =>
13    setToAddress(e.target.value)
14
15  const handleAmountInput = (e: React.ChangeEvent<HTMLInputElement>) =>
16    setAmount(e.target.value)
17
18  const sendTransaction = useCallback(() => {
19    const fromAddress = web3?.eth.getAccounts()?.[0]
20    const isToAddressValid = web3?.utils.isAddress(toAddress)
21
22    if (!fromAddress || !isToAddressValid || isNaN(Number(amount))) {
23      // handle errors
24    }
25
26    const txnParams = {
27      from: fromAddress,
28      to: toAddress,
29      value: web3.utils.toWei(amount, "ether"),
30      gas: 21000,
31    }
32    web3.eth
33      .sendTransaction(txnParams as any)
34      .on("transactionHash", (txHash: string) => {
35        setHash(txHash)
36        console.log("Transaction hash:", txHash)
37      })
38      .then((receipt: any) => {
39        setToAddress("")
40        setAmount("")
41        console.log("Transaction receipt:", receipt)
42      })
43      .catch(() => {
44        // handle errors
45      })
46  }, [web3, amount, toAddress])
47
48  // Render the component
49  return (
50    <div className="py-2 flex flex-col gap-2">
51      <input
52        className="text-black"
53        type="text"
54        onChange={handleAddressInput}
55        maxLength={40}
56        placeholder="Set Recipient Address"
57      />
58      <input
59        className="text-black"
60        type="text"
61        onChange={handleAmountInput}
62        maxLength={40}
63        placeholder="Set Amount To Send"
64      />
65      <button
66        type="button"
67        className="border border-white font-bold p-2 rounded-md"
68        onClick={sendTransaction}
69      >
70        Send ETH
71      </button>
72      {hash && (
73        <div className="w-[20vw] break-words mx-auto text-center">{`Tx Hash: ${hash}`}</div>
74      )}
75    </div>
76  )
77}
78
79export default SendTransaction

#SignMessage Component

The SignMessage component allows the user to sign a message using their wallet. Similar to the SendTransactioncomponent, this can be built the same way it would be without Magic.

Create a new file in components named SignMessage.tsx and paste the following code:

Typescript
01// src/app/components/SignMessage.tsx
02
03import { useState } from "react"
04import { useMagic } from "../context/MagicProvider"
05
06const SignMessage = () => {
07  // Use the MagicProvider to get the current instance of web3
08  const { web3 } = useMagic()
09
10  // Initialize state for message and signature
11  const [message, setMessage] = useState("")
12  const [signature, setSignature] = useState("")
13
14  // Define the handler for input change, it updates the message state with input value
15  const handleInput = (e: React.ChangeEvent<HTMLInputElement>) =>
16    setMessage(e.target.value)
17
18  // Define the signMessage function which is used to sign the message
19  const handleSignMessage = async () => {
20    const accounts = await web3?.eth.getAccounts()
21    const address = accounts?.[0]
22    if (address && web3) {
23      try {
24        // Sign the message using the connected wallet
25        const signedMessage = await web3.eth.personal.sign(message, address, "")
26        // Set the signature state with the signed message
27        setSignature(signedMessage)
28        // Do something with the signature
29      } catch (error) {
30        // Log any errors that occur during the signing process
31        console.error("handleSignMessage:", error)
32      }
33    }
34  }
35
36  // Render the component
37  return (
38    <div className="py-2 flex flex-col gap-2">
39      <input
40        className="text-black"
41        type="text"
42        onChange={handleInput}
43        maxLength={20}
44        placeholder="Set Message"
45      />
46      <button
47        type="button"
48        className="border border-white font-bold p-2 rounded-md"
49        onClick={handleSignMessage}
50      >
51        Sign Message
52      </button>
53      {signature && (
54        <div className="w-[20vw] break-words mx-auto text-center">{`Signature: ${signature}`}</div>
55      )}
56    </div>
57  )
58}
59
60export default SignMessage

#WalletDetail Component

The WalletDetail component will simply display the current user's address and balance. Just as with the SendTransaction and SignMessage components, this can be done the same way you would do it without Magic.

Create a new file in components named WalletDetail.tsx and add the following code:

Typescript
01// src/app/components/WalletDetail.tsx
02
03import { useEffect, useMemo, useState } from "react"
04import { useMagic } from "../context/MagicProvider"
05import { useUser } from "../context/UserContext"
06
07const WalletDetail = () => {
08  // Use the Web3Context to get the current instance of web3
09  const { web3 } = useMagic()
10  const { user } = useUser()
11
12  // Initialize state variable for balance
13  const [balance, setBalance] = useState("...")
14
15  useEffect(() => {
16    const getBalance = async () => {
17      if (!user?.address || !web3) return
18      try {
19        // If account and web3 are available, get the balance
20        const balance = await web3.eth.getBalance(user?.address)
21
22        // Convert the balance from Wei to Ether and set the state variable
23        setBalance(web3.utils.fromWei(balance, "ether").substring(0, 7))
24      } catch (error) {
25        console.error(error)
26      }
27    }
28
29    getBalance()
30  }, [web3, user])
31
32  // Render the account address and balance
33  return (
34    <div>
35      <p>Address: {user?.address}</p>
36      <p>Balance: {balance} ETH</p>
37    </div>
38  )
39}
40
41export default WalletDetail

#Final UI Updates

Finally, update the main index file to use the newly created components. Replace the contents of src/app/page.tsx with the following code:

Typescript
01// src/app/page.tsx
02
03"use client"
04import {
05  ConnectButton,
06  DisconnectButton,
07  ShowUIButton,
08  SignMessage,
09  WalletDetail
10} from "../app/components/index"
11import { useUser } from "../app/context/UserContext"
12
13export default function Home() {
14  const { user } = useUser()
15  return (
16    <main className="min-h-screen bg-black">
17      {user ?
18        <div className="p-2 flex flex-col w-[40vw] mx-auto">
19          <WalletDetail />
20          <ShowUIButton />
21          <SignMessage />
22          <DisconnectButton />
23        </div>
24        :
25        <div className="p-2">
26          <ConnectButton />
27        </div>
28      }
29    </main>
30  )
31}

Lastly, run the following command to start the application:

NPM
Yarn
01npm run dev

This command will start the development server and you should be able to view the application in your web browser at http://localhost:3000.

#Customize Your App

To customize the app, feel free to modify any of the code and restructure the project. This application uses our Dedicated Wallet. The Dedicated Wallet meets the widest variety of needs while still being incredibly simple to implement. In addition to the baked-in Login UI, it has plenty of customization options, supports social login through providers like GitHub and Discord, allows for enterprise multi-factor authentication, and more.

#Next Steps

We have a suite of resources to help developers and companies with a wide variety of use cases. Below are some ideas to get you started, but feel free to browse our documentation or reach out with specific questions.

Let's make some magic!