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

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:
- Go to cloud.walletconnect.com
- Sign in and create a new project
- Copy your Project ID
- 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:
getDefaultConfigfrom RainbowKit automatically configures connectors for MetaMask, Coinbase Wallet, WalletConnect, Rainbow, and more- No need to manually configure connectors like in wagmi 3.x
ssr: trueenables 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
RainbowKitProviderinsideQueryClientProvider - 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:
- Click the connected chain name in the button
- Select from the configured chains
- 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:
- Neumorphic connect button: Uses dual-directional box shadows (
29px 29px 59pxdark,-29px -29px 59pxlight) to create a soft, raised 3D effect - Gradient background: Cyan-to-blue-to-purple gradient for visual interest
- Interactive states: Hover reduces shadow distance, active adds inset shadows for a pressed effect
- Custom account button: Fixed minimum width (
min-w-[300px]), gradient background, displays both address and balance - 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:
| Package | Version | Notes |
|---|---|---|
| wagmi | 2.12.25 | Must be 2.x for RainbowKit 2.x |
| viem | 2.44.0 | Compatible with wagmi 2.x |
| @rainbow-me/rainbowkit | 2.2.10 | Requires wagmi ^2.9.0 |
| @tanstack/react-query | 5.90.17 | Required peer dependency |
| react | 19.0.0 | Works with 18+ |
If you need wagmi 3.x features, you'll need to build custom wallet UI without RainbowKit.
What I Learned
-
Version compatibility matters: RainbowKit doesn't support wagmi 3.x yet. Always check peer dependencies before upgrading.
-
RainbowKit provides excellent UX: The pre-built wallet modal, account management, and chain switching UI are production-ready and save significant development time.
-
Testing with Rabby: Rabby wallet works perfectly with the
injected()connector. RainbowKit automatically detects it and displays the Rabby logo. -
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.
-
Multi-chain is trivial: Adding support for multiple chains is as simple as adding them to the
chainsarray ingetDefaultConfig. The UI updates automatically. -
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.