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.

Feb 8, 2026/11 min readWeb3
ShareY
Multi-Chain Front-Ends: One App, Many Networks

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

lib/chains.ts
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}
Watch out

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.

hooks/use-balance.ts
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).

components/network-gate.tsx
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

lib/clients.ts
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.nativeCurrency has what you need — use it in fee display.
  • Gas estimation. Some L2s have an L1 data fee component that estimateGas alone misses. Use the chain-aware estimateFeesPerGas.
  • 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.

All posts

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.