April 30, 2026·8 min·By

MetaMask Integration in React: Connect Wallets the Right Way

MetaMaskWeb3Reactwagmiethers.js

Most Web3 wallet integrations are either too simple (only connect button) or built on outdated patterns. Here's a complete implementation using modern tools.

The Stack

Use wagmi + viem -- the current standard:

npm install wagmi viem @tanstack/react-query

Setup

// app/providers.tsx
'use client'
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet, sepolia } from 'wagmi/chains'
import { metaMask, coinbaseWallet, walletConnect } from 'wagmi/connectors'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const config = createConfig({
    chains: [mainnet, sepolia],
    connectors: [
        metaMask(),
        coinbaseWallet({ appName: 'My App' }),
        walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! })
    ],
    transports: {
        [mainnet.id]: http(process.env.NEXT_PUBLIC_MAINNET_RPC!),
        [sepolia.id]: http(),
    }
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
    return (
        <WagmiProvider config={config}>
            <QueryClientProvider client={queryClient}>
                {children}
            </QueryClientProvider>
        </WagmiProvider>
    )
}

Connect Button

'use client'
import { useConnect, useDisconnect, useAccount, useChainId, useSwitchChain } from 'wagmi'
import { mainnet } from 'wagmi/chains'

export function ConnectButton() {
    const { address, isConnected } = useAccount()
    const chainId = useChainId()
    const { connect, connectors } = useConnect()
    const { disconnect } = useDisconnect()
    const { switchChain } = useSwitchChain()

    if (isConnected) {
        return (
            <div className="flex items-center gap-2">
                {chainId !== mainnet.id && (
                    <button
                        onClick={() => switchChain({ chainId: mainnet.id })}
                        className="px-3 py-1.5 bg-red-500 text-white rounded text-sm"
                    >
                        Wrong Network
                    </button>
                )}
                <span className="font-mono text-sm">
                    {address?.slice(0, 6)}...{address?.slice(-4)}
                </span>
                <button onClick={() => disconnect()} className="btn-secondary">
                    Disconnect
                </button>
            </div>
        )
    }

    return (
        <div className="flex gap-2">
            {connectors.map(connector => (
                <button
                    key={connector.id}
                    onClick={() => connect({ connector })}
                    className="btn-primary"
                >
                    {connector.name}
                </button>
            ))}
        </div>
    )
}

Reading Contract Data

import { useReadContract } from 'wagmi'
import { erc20Abi } from 'viem'

const TOKEN_ADDRESS = '0x...'

export function TokenBalance({ userAddress }: { userAddress: `0x${string}` }) {
    const { data: balance, isLoading, error } = useReadContract({
        address: TOKEN_ADDRESS,
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [userAddress],
    })

    if (isLoading) return <span>Loading...</span>
    if (error) return <span>Error loading balance</span>

    return (
        <span>
            {balance ? (Number(balance) / 1e18).toFixed(4) : '0'} MTK
        </span>
    )
}

Writing to Contracts

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { parseEther } from 'viem'

export function DepositButton() {
    const { writeContract, data: hash, isPending, error } = useWriteContract()
    const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash })

    const deposit = () => {
        writeContract({
            address: VAULT_ADDRESS,
            abi: vaultAbi,
            functionName: 'deposit',
            value: parseEther('0.01'),
        })
    }

    return (
        <div>
            <button
                onClick={deposit}
                disabled={isPending || isConfirming}
                className="btn-primary"
            >
                {isPending ? 'Confirm in MetaMask...' :
                 isConfirming ? 'Waiting for confirmation...' :
                 'Deposit 0.01 ETH'}
            </button>
            {isSuccess && <p className="text-green-500">Deposited successfully!</p>}
            {error && <p className="text-red-500">Error: {error.message}</p>}
        </div>
    )
}

Signing Messages

import { useSignMessage } from 'wagmi'

export function SigninButton() {
    const { signMessage, data: signature, isPending } = useSignMessage()

    const handleSignin = () => {
        const nonce = crypto.randomUUID()
        // Store nonce to verify on server
        localStorage.setItem('auth_nonce', nonce)

        signMessage({
            message: `Sign in to My App\n\nNonce: ${nonce}\nTimestamp: ${Date.now()}`
        })
    }

    // When signature is ready, send to your backend
    useEffect(() => {
        if (signature) {
            verifySignatureOnServer(signature)
        }
    }, [signature])

    return (
        <button onClick={handleSignin} disabled={isPending}>
            {isPending ? 'Check MetaMask...' : 'Sign In with Ethereum'}
        </button>
    )
}

Common Errors and Fixes

// User rejected transaction
if (error.code === 4001) {
    showToast("Transaction rejected")
}

// Wrong network
if (error.code === -32002) {
    switchChain({ chainId: mainnet.id })
}

// Insufficient funds
if (error.message.includes("insufficient funds")) {
    showToast("Not enough ETH for gas")
}

// Contract reverted
if (error.message.includes("execution reverted")) {
    const reason = error.message.match(/reason: (.+)/)?.[1]
    showToast(reason || "Transaction failed")
}

wagmi handles wallet reconnection, chain switching, and real-time balance updates automatically. The key is proper error handling -- wallet interactions fail more often than regular API calls.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation