Post 13
Multi-Chain Front-Ends: One App, Many Networks
Supporting five chains is not five times the work if you structure it right — one config map, chain-scoped queries, a wrong-network flow that fixes itself, and addresses that are never hardcoded. The patterns that keep it sane.

A single-chain dApp is straightforward. The mess starts when you add a second chain and discover that contract addresses are scattered through the codebase as string literals, the RPC URL is hardcoded, and half the UI assumes the user is on mainnet. Do the structural work once and the fifth chain costs almost nothing.
One config map, keyed by chain id
1import { mainnet, base, arbitrum, optimism, polygon } from "viem/chains";2 3export const SUPPORTED_CHAINS = [mainnet, base, arbitrum, optimism, polygon];4 5type Deployment = {6 router: `0x${string}`;7 vault: `0x${string}`;8 usdc: `0x${string}`;9 subgraph: string;10};11 12export const DEPLOYMENTS: Record<number, Deployment> = {13 [mainnet.id]: {14 router: "0x1111...", vault: "0x2222...", usdc: "0xA0b8...",15 subgraph: "https://api.thegraph.com/subgraphs/name/x/mainnet",16 },17 [base.id]: {18 router: "0x3333...", vault: "0x4444...", usdc: "0x8335...",19 subgraph: "https://api.thegraph.com/subgraphs/name/x/base",20 },21 // ...22};23 24export function deployment(chainId: number) {25 const d = DEPLOYMENTS[chainId];26 if (!d) throw new UnsupportedChainError(chainId);27 return d;28}Never write a contract address as a literal in a component. Every address goes through deployment(chainId). The day you find a hardcoded 0x... in a JSX file is the day the app breaks on a chain switch.
Chain-scope every query key
The single most common multi-chain bug: the user switches from Base to Arbitrum and briefly sees their Base balance, because the React Query cache key did not include the chain. Put chainId in every key.
1"use client";2import { useAccount, useChainId } from "wagmi";3import { useQuery } from "@tanstack/react-query";4import { deployment } from "@/lib/chains";5 6export function useVaultBalance() {7 const { address } = useAccount();8 const chainId = useChainId();9 10 return useQuery({11 queryKey: ["vault-balance", chainId, address], // ← chainId first12 queryFn: () => readVaultBalance(chainId, address!),13 enabled: !!address,14 });15}Wrong-network UX that fixes itself
Do not just show a warning banner and leave the user to figure out how to switch. Detect the mismatch, offer the switch as a button, and handle the case where the chain is not in their wallet yet (wallet_addEthereumChain).
1"use client";2import { useAccount, useSwitchChain } from "wagmi";3import { SUPPORTED_CHAINS } from "@/lib/chains";4 5export function NetworkGate({6 need, children,7}: { need: number; children: React.ReactNode }) {8 const { chainId, isConnected } = useAccount();9 const { switchChain, isPending, error } = useSwitchChain();10 11 if (!isConnected || chainId === need) return <>{children}</>;12 13 const target = SUPPORTED_CHAINS.find((c) => c.id === need)!;14 return (15 <div className="rounded border border-primary/40 p-4">16 <p>This action runs on {target.name}.</p>17 <button disabled={isPending} onClick={() => switchChain({ chainId: need })}>18 {isPending ? "Check your wallet…" : `Switch to ${target.name}`}19 </button>20 {error?.name === "UserRejectedRequestError" && (21 <p>You declined the switch — no problem.</p>22 )}23 </div>24 );25}Per-chain RPC, with failover
1import { createPublicClient, fallback, http } from "viem";2import { SUPPORTED_CHAINS } from "./chains";3 4const RPCS: Record<number, string[]> = {5 [mainnet.id]: [process.env.NEXT_PUBLIC_RPC_MAINNET!, "https://eth.llamarpc.com"],6 [base.id]: [process.env.NEXT_PUBLIC_RPC_BASE!, "https://mainnet.base.org"],7};8 9const clients = new Map<number, ReturnType<typeof createPublicClient>>();10 11export function clientFor(chainId: number) {12 if (!clients.has(chainId)) {13 const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId)!;14 clients.set(chainId, createPublicClient({15 chain,16 transport: fallback(17 (RPCS[chainId] ?? []).map((url) => http(url, { batch: true })),18 { rank: true },19 ),20 }));21 }22 return clients.get(chainId)!;23}Things that differ by chain — and will surprise you
- Block time and finality. 12s on mainnet, ~2s on most L2s. Your confirmation logic and polling intervals should read from chain config, not be constants.
- Native token and decimals. MATIC on Polygon, ETH elsewhere.
chain.nativeCurrencyhas what you need — use it in fee display. - Gas estimation. Some L2s have an L1 data fee component that
estimateGasalone misses. Use the chain-awareestimateFeesPerGas. - Which tokens exist. USDC has a different address on every chain, and 'bridged' vs 'native' USDC are different tokens on some. The config map is the only source of truth.
- Explorer URLs.
chain.blockExplorers.default.url— never hardcode etherscan.io.
Multi-chain is a data-modelling problem, not a blockchain problem. Get everything chain-specific into one typed map and the UI barely has to care which network it is on.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.