Multi-Chain Wallet Connection with wagmi 2.x + RainbowKit

A man standing in a tunnel with a sky background

Connecting wallets across multiple chains doesn't have to be complicated. With wagmi 2.x and RainbowKit, you get a beautiful, polished wallet connection experience that supports 100+ wallets out of the box—including MetaMask, Coinbase Wallet, Rabby, Rainbow, and any WalletConnect-compatible wallet.

This guide shows you how to build a multi-chain wallet connector that works with Ethereum, Base, Arbitrum, Optimism, and Polygon.

Why wagmi 2.x + RainbowKit?

wagmi provides React hooks for Ethereum interactions. RainbowKit adds a beautiful, production-ready UI on top of wagmi with minimal setup.

Key benefits:

  • Pre-built wallet modal with excellent UX
  • Automatic support for 100+ wallets
  • Built-in chain switching UI
  • Account modal with copy address, view on explorer, and disconnect
  • Mobile-friendly with WalletConnect support
  • Zero backend requirements
  • Type-safe with TypeScript

Important: RainbowKit requires wagmi 2.x. If you're using wagmi 3.x, you'll need to downgrade to use RainbowKit.

Live Demo

Try connecting with MetaMask, Coinbase Wallet, Rabby, or any WalletConnect-compatible wallet.

Prerequisites

This guide uses:

  • Next.js 15 with App Router
  • React 19
  • wagmi 2.12.25 (wagmi 2.x, NOT 3.x)
  • viem 2.44.0
  • @rainbow-me/rainbowkit 2.2.10
  • @tanstack/react-query 5.90.17

Critical: These exact versions matter. RainbowKit 2.2.10 requires wagmi 2.x and will not work with wagmi 3.x.

Step 1: Install Dependencies

Install wagmi 2.x, viem, RainbowKit, and TanStack Query:

npm install [email protected] [email protected] @rainbow-me/[email protected] @tanstack/[email protected]

Or with bun:

bun add [email protected] [email protected] @rainbow-me/[email protected] @tanstack/[email protected]

Step 2: Get a WalletConnect Project ID (Optional)

For full WalletConnect support, get a free project ID:

  1. Go to cloud.walletconnect.com
  2. Sign in and create a new project
  3. Copy your Project ID
  4. Add to .env.local:
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id_here

Note: Without a WalletConnect project ID, MetaMask, Coinbase Wallet, and Rabby will still work. WalletConnect is only needed for mobile wallets and certain desktop wallets that rely on the WalletConnect protocol.

Making WalletConnect Truly Optional

If you're building a dApp that primarily targets desktop users with browser extension wallets, you can skip WalletConnect entirely:

  • Browser extensions work without WalletConnect: MetaMask, Coinbase Wallet, Rabby, Brave Wallet, and other injected wallets connect directly through the browser without any project ID
  • Fallback handling: RainbowKit uses 'demo-project-id' as a fallback, which allows the library to initialize properly
  • No runtime errors: The app works perfectly without a real project ID for extension wallets

When you DO need WalletConnect:

  • Mobile wallet support (scanning QR codes to connect)
  • Wallets that don't inject into the browser
  • WalletConnect-specific features like wallet linking

For this demo, we're using the fallback approach, which works great for development and for apps that primarily target desktop users.

Step 3: Configure wagmi with RainbowKit

Create lib/wagmi.ts:

import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { mainnet, base, arbitrum, optimism, polygon } from 'wagmi/chains';

export const wagmiConfig = getDefaultConfig({
  appName: 'Your App Name',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || 'demo-project-id',
  chains: [mainnet, base, arbitrum, optimism, polygon],
  ssr: true, // Enable server-side rendering support
});

What's happening:

  • getDefaultConfig from RainbowKit automatically configures connectors for MetaMask, Coinbase Wallet, WalletConnect, Rainbow, and more
  • No need to manually configure connectors like in wagmi 3.x
  • ssr: true enables proper server-side rendering support
  • Add any EVM-compatible chains from wagmi/chains

Step 4: Set Up Providers

Create app/providers.tsx:

'use client';

import { ReactNode } from 'react';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { wagmiConfig } from '@/lib/wagmi';
import '@rainbow-me/rainbowkit/styles.css';

type ProvidersProps = {
  children: ReactNode;
};

const queryClient = new QueryClient();

export function WagmiProviders({ children }: ProvidersProps) {
  return (
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Key points:

  • Import RainbowKit CSS styles
  • Wrap RainbowKitProvider inside QueryClientProvider
  • TanStack Query is required for wagmi hooks to work
  • Mark as 'use client' since this uses client-side hooks

Wrap your app with the provider in your layout:

import { WagmiProviders } from '@/app/providers';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <WagmiProviders>
          {children}
        </WagmiProviders>
      </body>
    </html>
  );
}

Step 5: Build the Wallet Connection Component

Create your component with RainbowKit's ConnectButton:

'use client';

import { useState, useEffect } from 'react';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useBalance, useChainId } from 'wagmi';
import { mainnet, base, arbitrum, optimism, polygon } from 'wagmi/chains';
import type { Address } from 'viem';

const CHAINS = [mainnet, base, arbitrum, optimism, polygon];

export function WalletDemo() {
  const [mounted, setMounted] = useState(false);
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  const { data: balanceData, isLoading: isLoadingBalance } = useBalance({
    address: address as Address,
  });

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    return <div>Loading...</div>;
  }

  const currentChain = CHAINS.find(chain => chain.id === chainId);

  return (
    <div className="max-w-2xl mx-auto p-6">
      <ConnectButton />

      {isConnected && (
        <div className="mt-6 space-y-4">
          <div className="p-4 bg-slate-800 rounded-lg">
            <p className="text-sm text-slate-400">Connected Chain</p>
            <p className="text-lg font-semibold text-white">
              {currentChain?.name || 'Unknown Chain'}
            </p>
          </div>

          <div className="p-4 bg-slate-800 rounded-lg">
            <p className="text-sm text-slate-400">Balance</p>
            {isLoadingBalance ? (
              <div className="h-8 bg-slate-700 rounded animate-pulse" />
            ) : balanceData ? (
              <p className="text-2xl font-bold text-white">
                {parseFloat(balanceData.formatted).toFixed(4)} {balanceData.symbol}
              </p>
            ) : (
              <p className="text-slate-400">Unable to fetch balance</p>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

Hydration safety: The mounted state prevents hydration mismatches between server and client rendering. This pattern is essential for Next.js with wagmi.

How It Works

RainbowKit Components

ConnectButton: Pre-built button that handles:

  • Opening wallet selection modal
  • Displaying connected wallet info
  • Chain switching UI
  • Account modal (copy address, view on explorer, disconnect)

wagmi Hooks

useAccount(): Returns connection status and wallet address

const { address, isConnected } = useAccount();

useBalance(): Reads native token balance (ETH)

const { data: balanceData, isLoading } = useBalance({
  address: address as Address,
});

useChainId(): Returns the currently connected chain ID

const chainId = useChainId();

All hooks are reactive—they automatically update when the wallet state changes.

Supported Wallets

RainbowKit automatically supports 100+ wallets including:

  • Browser Extensions: MetaMask, Coinbase Wallet, Rabby, Rainbow, Frame, Brave Wallet
  • Mobile Wallets: Rainbow, MetaMask Mobile, Coinbase Wallet, Trust Wallet, Argent
  • Hardware Wallets: Ledger Live
  • Any WalletConnect-compatible wallet

Users can also manually enter any WalletConnect URI.

Chain Switching

RainbowKit's ConnectButton includes a built-in chain switcher. Users can:

  1. Click the connected chain name in the button
  2. Select from the configured chains
  3. Approve the switch in their wallet

No additional code needed—it's all handled by RainbowKit.

Customizing RainbowKit

RainbowKit is highly customizable. You can use built-in themes or create completely custom UIs.

Option 1: Built-in Themes

Apply pre-built themes to match your brand:

import { RainbowKitProvider, darkTheme, lightTheme, midnightTheme } from '@rainbow-me/rainbowkit';

<RainbowKitProvider theme={darkTheme()}>
  {children}
</RainbowKitProvider>

// Or customize a theme
<RainbowKitProvider
  theme={darkTheme({
    accentColor: '#7b3ff2',
    accentColorForeground: 'white',
    borderRadius: 'medium',
  })}
>
  {children}
</RainbowKitProvider>

Option 2: Custom Button UI with ConnectButton.Custom

For complete control over the button design, use ConnectButton.Custom. Here's how to build a custom neumorphic-style button:

import { ConnectButton } from '@rainbow-me/rainbowkit';

<ConnectButton.Custom>
  {({
    account,
    chain,
    openAccountModal,
    openChainModal,
    openConnectModal,
    authenticationStatus,
    mounted,
  }) => {
    const ready = mounted && authenticationStatus !== 'loading';
    const connected = ready && account && chain;

    return (
      <div className="flex flex-wrap gap-3">
        {(() => {
          if (!connected) {
            return (
              <button
                onClick={openConnectModal}
                type="button"
                style={{
                  borderRadius: '15px',
                  background: 'linear-gradient(145deg, #22d3ee, #3b82f6, #9333ea)',
                  boxShadow: '29px 29px 59px #1e40af, -29px -29px 59px #60a5fa'
                }}
                className="cursor-pointer relative px-8 py-4 text-white font-bold transition-all duration-200 hover:shadow-[25px_25px_50px_#1e40af,-25px_-25px_50px_#60a5fa] active:shadow-[inset_20px_20px_40px_#1e40af,inset_-20px_-20px_40px_#60a5fa]"
              >
                <span className="relative z-10 flex items-center gap-3">
                  <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
                    <path d="M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>
                  </svg>
                  Connect Wallet
                </span>
              </button>
            );
          }

          if (chain.unsupported) {
            return (
              <button onClick={openChainModal} type="button">
                Wrong network
              </button>
            );
          }

          return (
            <div className="flex flex-wrap gap-3">
              {/* Chain switcher button */}
              <button
                onClick={openChainModal}
                type="button"
                className="flex items-center gap-2 px-5 py-3 bg-slate-800 hover:bg-slate-700 text-white font-medium rounded-xl transition-all"
              >
                {chain.hasIcon && chain.iconUrl && (
                  <img
                    alt={chain.name ?? 'Chain icon'}
                    src={chain.iconUrl}
                    className="w-6 h-6"
                  />
                )}
                {chain.name}
              </button>

              {/* Account button */}
              <button
                onClick={openAccountModal}
                type="button"
                className="flex items-center justify-between px-5 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white font-medium rounded-xl min-w-[300px]"
              >
                <div className="flex flex-col items-start">
                  <span className="font-semibold text-sm">{account.displayName}</span>
                  {account.displayBalance && (
                    <span className="text-xs text-white/80 font-mono">
                      {account.displayBalance}
                    </span>
                  )}
                </div>
                <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
                </svg>
              </button>
            </div>
          );
        })()}
      </div>
    );
  }}
</ConnectButton.Custom>

Key customizations in this example:

  1. Neumorphic connect button: Uses dual-directional box shadows (29px 29px 59px dark, -29px -29px 59px light) to create a soft, raised 3D effect
  2. Gradient background: Cyan-to-blue-to-purple gradient for visual interest
  3. Interactive states: Hover reduces shadow distance, active adds inset shadows for a pressed effect
  4. Custom account button: Fixed minimum width (min-w-[300px]), gradient background, displays both address and balance
  5. Wallet icon: Custom SVG wallet icon instead of default styling

Neumorphic design principles:

  • Light shadows from one direction (simulates light source)
  • Dark shadows from opposite direction (simulates depth)
  • Inset shadows on active state (simulates button being pressed in)
  • Soft, rounded corners for the "soft UI" aesthetic

This gives you complete control over every pixel while RainbowKit still handles all the wallet connection logic, modals, and state management.

See RainbowKit docs for more customization options.

Package Version Compatibility

Critical: RainbowKit 2.2.10 requires wagmi 2.x. Here's the compatibility matrix:

PackageVersionNotes
wagmi2.12.25Must be 2.x for RainbowKit 2.x
viem2.44.0Compatible with wagmi 2.x
@rainbow-me/rainbowkit2.2.10Requires wagmi ^2.9.0
@tanstack/react-query5.90.17Required peer dependency
react19.0.0Works with 18+

If you need wagmi 3.x features, you'll need to build custom wallet UI without RainbowKit.

What I Learned

  1. Version compatibility matters: RainbowKit doesn't support wagmi 3.x yet. Always check peer dependencies before upgrading.

  2. RainbowKit provides excellent UX: The pre-built wallet modal, account management, and chain switching UI are production-ready and save significant development time.

  3. Testing with Rabby: Rabby wallet works perfectly with the injected() connector. RainbowKit automatically detects it and displays the Rabby logo.

  4. WalletConnect is optional: You don't need a WalletConnect project ID for browser extension wallets. It's only required for mobile wallets using the WalletConnect protocol.

  5. Multi-chain is trivial: Adding support for multiple chains is as simple as adding them to the chains array in getDefaultConfig. The UI updates automatically.

  6. Hydration safety is critical: Always use the mounted state pattern with Next.js SSR to avoid hydration mismatches.

Common Issues

"Module not found: Can't resolve 'wagmi/experimental'": You're using wagmi 3.x with a library that requires wagmi 2.x. Downgrade wagmi.

Wallet not connecting: Check that you've imported RainbowKit CSS styles.

Hydration mismatch errors: Implement the mounted state pattern shown above.

Balance showing as 0: You might be on a testnet chain without any test ETH. Switch to mainnet or get testnet tokens from a faucet.

Next Steps

  • Add transaction functionality with useWriteContract()
  • Implement signing messages with useSignMessage()
  • Add ERC-20 token balances with additional useBalance() calls
  • Customize RainbowKit theme to match your brand
  • Add analytics to track wallet connections

Resources

Implementing ERC20 Token Interactions: USDC Donations

The demo component includes a real-world example of ERC20 token interactions by implementing a USDC donation feature across all supported chains. This demonstrates how to read token balances, initiate transfers, and track transaction status.

Setting Up USDC Contract Addresses

Different chains use different USDC contract addresses. We maintain a mapping of chain IDs to their respective USDC addresses:

const USDC_ADDRESSES: Record<number, Address> = {
  [mainnet.id]: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
  [base.id]: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
  [arbitrum.id]: '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
  [optimism.id]: '0x0b2c639c533813f4aa9d7837caf62653d097ff85',
  [polygon.id]: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359',
  [pulsechain.id]: '0x15d38573d2feeb82e7ad5187ab8c1d52810b1f07',
};

const DONATION_ADDRESS: Address = '0x4a5BBCdf73525e26167B7BEaf2129bc62E7E4459';

Reading ERC20 Token Balances

Use the useReadContract hook to read USDC balances. The ERC20 standard balanceOf function returns the token balance for a given address:

const ERC20_ABI = [
  {
    inputs: [{ name: 'account', type: 'address' }],
    name: 'balanceOf',
    outputs: [{ name: '', type: 'uint256' }],
    stateMutability: 'view',
    type: 'function',
  },
  {
    inputs: [
      { name: 'recipient', type: 'address' },
      { name: 'amount', type: 'uint256' }
    ],
    name: 'transfer',
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'nonpayable',
    type: 'function',
  },
] as const;

const { data: usdcBalance, refetch: refetchUsdcBalance } = useReadContract({
  address: usdcAddress,
  abi: ERC20_ABI,
  functionName: 'balanceOf',
  args: address ? [address] : undefined,
  query: {
    enabled: !!address && !!usdcAddress,
  },
});

Format the balance using formatUnits from viem, accounting for USDC's 6 decimal places:

{usdcBalance !== undefined && (
  <p className="text-2xl font-bold text-white">
    {formatUnits(usdcBalance as bigint, 6)} USDC
  </p>
)}

Implementing Token Transfers

Use useWriteContract to initiate ERC20 token transfers. The donation handler converts the user input to the proper units before calling the contract:

const { writeContract, data: hash, isPending, error } = useWriteContract();

const handleDonate = () => {
  if (!donationAmount || !usdcAddress) return;

  try {
    const amount = parseUnits(donationAmount, 6); // USDC has 6 decimals

    writeContract({
      address: usdcAddress,
      abi: ERC20_ABI,
      functionName: 'transfer',
      args: [DONATION_ADDRESS, amount],
    });
  } catch (err) {
    console.error('Error preparing transaction:', err);
  }
};

Transaction Status and Confirmation

Track transaction confirmation status using useWaitForTransactionReceipt:

const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
  hash,
});

Display transaction status with a link to the block explorer:

{hash && (
  <div className="mt-3 p-3 bg-slate-900 rounded-lg">
    <p className="text-xs text-slate-400 mb-1">Transaction Hash:</p>
    <a
      href={`${currentChain?.blockExplorers?.default.url}/tx/${hash}`}
      target="_blank"
      rel="noopener noreferrer"
      className="text-xs text-green-400 hover:text-green-300 font-mono break-all"
    >
      {hash}
    </a>
    {isConfirming && (
      <p className="text-xs text-yellow-400 mt-2">Waiting for confirmation...</p>
    )}
    {isConfirmed && (
      <p className="text-xs text-green-400 mt-2">✓ Transaction confirmed! Thank you!</p>
    )}
  </div>
)}

Auto-Refresh After Confirmation

Automatically refresh the USDC balance and clear the input field after successful confirmation:

useEffect(() => {
  if (isConfirmed) {
    setDonationAmount('');
    refetchUsdcBalance();
  }
}, [isConfirmed, refetchUsdcBalance]);

This pattern ensures users see their updated balance immediately after a successful donation, providing clear feedback that their transaction was processed.