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
If you want to add Magic to an existing project, please skip this step.
Open a shell and run the following command:
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:
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:
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 useMagic
hook 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:
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:
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: ConnectButton
, DisconnectButton
, ShowUIButton
, SendTransaction
, SignMessage
, 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:
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:
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:
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:
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 SendTransaction
component, 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:
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:
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:
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:
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.
Add support for OAuth social providers like Google, Github, and Discord
Add support for one or more of the 25+ blockchains accessible through Magic
Use Magic across a variety of platforms, including Web, iOS, Android, Unity, and more
Learn more about Magic's security framework and how it can make your applications more secure
Read Magic's Whitepaper