IAMUVIN

Web3 Development

wagmi and viem: The Modern Web3 Frontend Stack

Uvin Vindula·May 6, 2024·10 min read
Share

TL;DR

wagmi and viem have replaced ethers.js as my default Web3 frontend stack. viem is a low-level TypeScript client for Ethereum that is tree-shakeable, type-safe down to the ABI level, and roughly 35x smaller than ethers.js in bundle size. wagmi is a collection of React hooks built on top of viem and TanStack Query that handles wallet connections, contract reads, transaction writes, and chain switching with almost no boilerplate. Combined with RainbowKit for wallet UI, this stack gives you a production-grade dApp frontend in under an hour. This article walks through the full setup with Next.js, real contract interaction patterns I use on client projects, multi-chain configuration, React Query integration, and a practical migration guide if you are coming from ethers.js.


Why wagmi + viem Over ethers.js

I used ethers.js for two years. It was the standard. Every tutorial used it, every project expected it, and it mostly worked. But after shipping three dApps in production, the cracks became impossible to ignore.

The first problem is bundle size. ethers.js v6 ships at roughly 120KB minified and gzipped. That is a lot of JavaScript for a library where most projects use maybe 15% of the API surface. You pay for every utility, every ABI coder, every provider abstraction — whether you use them or not.

viem takes a fundamentally different approach. It is tree-shakeable by design. If you only use readContract and writeContract, that is all that ends up in your bundle. In one client project, switching from ethers.js to viem dropped the Web3-related bundle from 118KB to 14KB. That is not a marginal improvement — it is a different category of performance.

The second problem is TypeScript support. ethers.js has types, but they are not deeply integrated with your contract ABIs. When you call contract.balanceOf(address), TypeScript does not know the return type is bigint unless you manually type it. You get generic any coming back from most contract calls, which means runtime surprises.

viem infers types directly from your ABI. If your ABI says balanceOf returns uint256, the TypeScript return type is bigint. If you misspell a function name, you get a compile-time error. If you pass the wrong number of arguments, TypeScript catches it before your code ever runs. This is not a nice-to-have — this is the difference between catching bugs at build time and catching them in production.

The third problem is the React integration story. ethers.js has no opinion about React. You end up building your own hooks, managing your own connection state, writing your own caching logic, and reinventing solutions that wagmi gives you for free. Every project I built with ethers.js had a different useContract hook, a different wallet connection flow, a different way of handling transaction states.

wagmi solves all of this. It is a set of React hooks purpose-built for Ethereum interactions, backed by TanStack Query for caching and state management. Wallet connection, chain switching, contract reads, transaction writes, event watching — all handled through hooks that follow React conventions and integrate with the React lifecycle.

Here is the honest comparison:

Concernethers.jswagmi + viem
Bundle size~120KB gzipped~14KB (tree-shaken)
TypeScriptGeneric typesABI-level inference
React integrationRoll your ownPurpose-built hooks
CachingManualTanStack Query built-in
Multi-chainManual provider switchingDeclarative chain config
Tree-shakingNoYes
BigInt handlingBigNumber classNative BigInt
Wallet connectionManualConnectors + UI libraries

I switched every active project to wagmi + viem over the course of a month. Not one regret.


Setting Up with Next.js

The initial setup is straightforward. I will walk through a Next.js 14+ App Router project because that is what I use for every client dApp.

Install the dependencies:

bash
npm install wagmi viem @tanstack/react-query

Create the wagmi configuration. I keep this in a dedicated file because it is referenced by both the provider setup and any server-side code that needs chain information:

typescript
// src/config/wagmi.ts
import { http, createConfig } from "wagmi";
import { mainnet, arbitrum, base, optimism } from "wagmi/chains";

export const config = createConfig({
  chains: [mainnet, arbitrum, base, optimism],
  transports: {
    [mainnet.id]: http(process.env.NEXT_PUBLIC_ETH_RPC_URL),
    [arbitrum.id]: http(process.env.NEXT_PUBLIC_ARB_RPC_URL),
    [base.id]: http(process.env.NEXT_PUBLIC_BASE_RPC_URL),
    [optimism.id]: http(process.env.NEXT_PUBLIC_OP_RPC_URL),
  },
});

A few things to note here. The http transport accepts a custom RPC URL. In production, you should always use a dedicated provider like Alchemy or Infura rather than the public defaults. Public RPCs have aggressive rate limits and will fail under real user load. I have seen this kill a launch day — do not rely on defaults.

Next, set up the provider wrapper. In the App Router, this needs to be a Client Component:

typescript
// src/providers/web3-provider.tsx
"use client";

import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { config } from "@/config/wagmi";

export function Web3Provider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 5 * 1000,
        gcTime: 10 * 60 * 1000,
      },
    },
  }));

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

I initialize QueryClient inside useState to avoid creating a new instance on every render. The staleTime of 5 seconds means contract reads will not refetch for 5 seconds after the initial fetch — a reasonable default for most dApp data. Adjust this based on how frequently your contract state changes.

Wrap your root layout:

typescript
// src/app/layout.tsx
import { Web3Provider } from "@/providers/web3-provider";

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

Connecting Wallets with RainbowKit

For wallet connection UI, I use RainbowKit. It handles the modal, the wallet list, the chain switcher, and the connected state display. You could build this yourself, but RainbowKit does it well and your users already recognize the interface.

bash
npm install @rainbow-me/rainbowkit

Update the wagmi config to use RainbowKit's connector setup:

typescript
// src/config/wagmi.ts
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { mainnet, arbitrum, base, optimism } from "wagmi/chains";

export const config = getDefaultConfig({
  appName: "My dApp",
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
  chains: [mainnet, arbitrum, base, optimism],
});

The projectId comes from WalletConnect Cloud. You need one for WalletConnect v2 support, which covers most mobile wallets. It takes two minutes to set up at cloud.walletconnect.com.

Update the provider:

typescript
// src/providers/web3-provider.tsx
"use client";

import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { config } from "@/config/wagmi";
import "@rainbow-me/rainbowkit/styles.css";

export function Web3Provider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 5 * 1000,
        gcTime: 10 * 60 * 1000,
      },
    },
  }));

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider theme={darkTheme({
          accentColor: "#F7931A",
          borderRadius: "medium",
        })}>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Now adding a connect button anywhere in your app is one line:

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

export function Header() {
  return (
    <header className="flex items-center justify-between p-4">
      <h1>My dApp</h1>
      <ConnectButton />
    </header>
  );
}

RainbowKit handles MetaMask, Coinbase Wallet, WalletConnect, Rainbow, and several others out of the box. The modal adapts to what is installed in the user's browser. On mobile, it shows QR codes for WalletConnect-compatible wallets. This is the kind of UX polish that takes weeks to build manually and minutes to set up with RainbowKit.


Reading Contract Data

Reading on-chain data is where wagmi really shines. The useReadContract hook handles fetching, caching, error states, loading states, and automatic refetching — all with full type inference from your ABI.

First, define your contract ABI. I keep ABIs in a dedicated directory and export them as const assertions so TypeScript can infer the exact types:

typescript
// src/contracts/erc20-abi.ts
export const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "totalSupply",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "symbol",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ name: "", type: "string" }],
  },
] as const;

The as const assertion is critical. Without it, TypeScript treats the ABI as a generic array and you lose all type inference. With it, wagmi knows exactly what balanceOf returns, what arguments it takes, and what types they are.

Now use it in a component:

typescript
"use client";

import { useReadContract, useAccount } from "wagmi";
import { formatUnits } from "viem";
import { erc20Abi } from "@/contracts/erc20-abi";

const TOKEN_ADDRESS = "0x..." as const;

export function TokenBalance() {
  const { address } = useAccount();

  const { data: balance, isLoading, error } = useReadContract({
    address: TOKEN_ADDRESS,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [address!],
    query: {
      enabled: Boolean(address),
    },
  });

  if (!address) return <p>Connect your wallet to view balance</p>;
  if (isLoading) return <p>Loading balance...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <p>Balance: {formatUnits(balance ?? 0n, 18)} tokens</p>
  );
}

Notice a few things. The query.enabled option prevents the hook from firing when address is undefined — this is a TanStack Query feature that wagmi exposes directly. The balance variable is typed as bigint | undefined because wagmi infers it from the ABI output type. And formatUnits from viem handles the decimal conversion cleanly.

For reading multiple values from the same contract, use useReadContracts to batch them into a single request:

typescript
"use client";

import { useReadContracts } from "wagmi";
import { formatUnits } from "viem";
import { erc20Abi } from "@/contracts/erc20-abi";

const TOKEN_ADDRESS = "0x..." as const;

export function TokenInfo({ userAddress }: { userAddress: `0x${string}` }) {
  const { data, isLoading } = useReadContracts({
    contracts: [
      {
        address: TOKEN_ADDRESS,
        abi: erc20Abi,
        functionName: "symbol",
      },
      {
        address: TOKEN_ADDRESS,
        abi: erc20Abi,
        functionName: "totalSupply",
      },
      {
        address: TOKEN_ADDRESS,
        abi: erc20Abi,
        functionName: "balanceOf",
        args: [userAddress],
      },
    ],
  });

  if (isLoading) return <p>Loading...</p>;

  const [symbol, totalSupply, balance] = data ?? [];

  return (
    <div>
      <p>Token: {symbol?.result}</p>
      <p>Supply: {formatUnits(totalSupply?.result ?? 0n, 18)}</p>
      <p>Balance: {formatUnits(balance?.result ?? 0n, 18)}</p>
    </div>
  );
}

This batches all three calls into a single multicall, which is both faster and cheaper on RPC credits than three separate requests. I use this pattern on every dashboard page.


Writing Transactions

Writing to contracts follows a different pattern because transactions are asynchronous, require user confirmation, and can fail at multiple stages. wagmi handles this with useWriteContract:

typescript
"use client";

import { useWriteContract, useAccount } from "wagmi";
import { parseUnits } from "viem";
import { erc20Abi } from "@/contracts/erc20-abi";

const TOKEN_ADDRESS = "0x..." as const;

export function TransferForm() {
  const { address } = useAccount();
  const { writeContract, isPending, isSuccess, error } = useWriteContract();

  function handleTransfer(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const to = formData.get("to") as `0x${string}`;
    const amount = formData.get("amount") as string;

    writeContract({
      address: TOKEN_ADDRESS,
      abi: erc20Abi,
      functionName: "transfer",
      args: [to, parseUnits(amount, 18)],
    });
  }

  if (!address) return <p>Connect wallet to transfer</p>;

  return (
    <form onSubmit={handleTransfer}>
      <input name="to" placeholder="Recipient address" required />
      <input name="amount" placeholder="Amount" type="number" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Confirming..." : "Transfer"}
      </button>
      {isSuccess && <p>Transfer submitted!</p>}
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

The isPending state covers the period where the user is confirming in their wallet. isSuccess fires when the transaction has been submitted to the network — not when it is confirmed on-chain. That distinction matters, and it leads to the next section.


Transaction Status and Confirmations

A common mistake I see in dApp frontends is treating transaction submission as confirmation. The user clicks "Stake," the wallet confirms, the UI shows a success message — but the transaction might still revert on-chain. Users see "Success!" and then their balance has not changed. This erodes trust fast.

wagmi solves this with useWaitForTransactionReceipt:

typescript
"use client";

import {
  useWriteContract,
  useWaitForTransactionReceipt,
} from "wagmi";
import { parseUnits } from "viem";

export function StakeButton({ amount }: { amount: string }) {
  const {
    data: hash,
    writeContract,
    isPending: isWritePending,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    confirmations: 2,
  });

  function handleStake() {
    writeContract({
      address: "0x...",
      abi: stakingAbi,
      functionName: "stake",
      args: [parseUnits(amount, 18)],
    });
  }

  return (
    <div>
      <button
        onClick={handleStake}
        disabled={isWritePending || isConfirming}
      >
        {isWritePending && "Waiting for wallet..."}
        {isConfirming && "Confirming on-chain..."}
        {!isWritePending && !isConfirming && "Stake"}
      </button>

      {isConfirmed && (
        <p className="text-green-500">
          Staked successfully! Tx: {hash}
        </p>
      )}
      {receiptError && (
        <p className="text-red-500">
          Transaction failed: {receiptError.message}
        </p>
      )}
    </div>
  );
}

The flow is: user clicks -> wallet popup (isWritePending) -> transaction submitted (hash available) -> waiting for block confirmations (isConfirming) -> confirmed or failed (isConfirmed or receiptError). I show distinct UI states for each phase because users need to know exactly what is happening. The confirmations: 2 parameter waits for two block confirmations before reporting success, which provides reasonable finality on most L2 chains.


Multi-Chain Configuration

Every production dApp I build supports multiple chains. The days of Ethereum-only frontends are over — your users are on Arbitrum, Base, Optimism, and increasingly on newer L2s. wagmi makes multi-chain support declarative rather than imperative.

The chain configuration in createConfig defines which chains your app supports. But the real power is in contract addresses that differ per chain:

typescript
// src/contracts/addresses.ts
import { mainnet, arbitrum, base, optimism } from "wagmi/chains";

export const CONTRACT_ADDRESSES = {
  staking: {
    [mainnet.id]: "0xaaa...",
    [arbitrum.id]: "0xbbb...",
    [base.id]: "0xccc...",
    [optimism.id]: "0xddd...",
  },
  token: {
    [mainnet.id]: "0xeee...",
    [arbitrum.id]: "0xfff...",
    [base.id]: "0x111...",
    [optimism.id]: "0x222...",
  },
} as const;

type SupportedChainId = keyof typeof CONTRACT_ADDRESSES.staking;

export function getContractAddress(
  contract: keyof typeof CONTRACT_ADDRESSES,
  chainId: number
): `0x${string}` {
  const addresses = CONTRACT_ADDRESSES[contract];
  const address = addresses[chainId as SupportedChainId];
  if (!address) throw new Error(`No ${contract} address for chain ${chainId}`);
  return address as `0x${string}`;
}

Then in your components, use useChainId to get the current chain and look up the right address:

typescript
"use client";

import { useReadContract, useAccount, useChainId } from "wagmi";
import { getContractAddress } from "@/contracts/addresses";
import { stakingAbi } from "@/contracts/staking-abi";

export function StakingDashboard() {
  const { address } = useAccount();
  const chainId = useChainId();

  const { data: stakedBalance } = useReadContract({
    address: getContractAddress("staking", chainId),
    abi: stakingAbi,
    functionName: "stakedBalance",
    args: [address!],
    query: { enabled: Boolean(address) },
  });

  // Component renders with correct contract for whichever chain the user is on
}

When the user switches chains in RainbowKit, useChainId updates, the contract address changes, and the hook automatically refetches from the correct contract on the new chain. No imperative provider switching. No manual cache invalidation. It just works.


React Query Integration

One of wagmi's best architectural decisions is building on TanStack Query. This means every contract read is a query with all the caching, refetching, and state management features you get from React Query.

Here are the patterns I use most:

Polling for real-time data:

typescript
const { data: price } = useReadContract({
  address: oracleAddress,
  abi: oracleAbi,
  functionName: "latestAnswer",
  query: {
    refetchInterval: 10_000, // Poll every 10 seconds
  },
});

Invalidating after a write:

typescript
import { useQueryClient } from "@tanstack/react-query";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";

export function useStake() {
  const queryClient = useQueryClient();
  const { data: hash, writeContract } = useWriteContract();

  const { isSuccess } = useWaitForTransactionReceipt({
    hash,
    confirmations: 1,
  });

  // Invalidate staking queries after confirmation
  if (isSuccess) {
    queryClient.invalidateQueries({ queryKey: ["readContract"] });
  }

  return { writeContract, hash, isSuccess };
}

Dependent queries:

typescript
const { data: poolAddress } = useReadContract({
  address: factoryAddress,
  abi: factoryAbi,
  functionName: "getPool",
  args: [tokenA, tokenB, fee],
});

const { data: liquidity } = useReadContract({
  address: poolAddress,
  abi: poolAbi,
  functionName: "liquidity",
  query: {
    enabled: Boolean(poolAddress), // Only fetch when pool address is resolved
  },
});

This chaining pattern eliminates fetch waterfalls because the second query fires immediately when the first resolves. No useEffect, no intermediate state management, no manual orchestration.


Common Patterns I Use

After building several production dApps with this stack, certain patterns appear in every project. Here are the ones I reach for most.

Token approval flow before a transaction:

typescript
"use client";

import { useReadContract, useWriteContract, useAccount } from "wagmi";
import { erc20Abi } from "viem";
import { maxUint256 } from "viem";

export function useTokenApproval(
  tokenAddress: `0x${string}`,
  spenderAddress: `0x${string}`
) {
  const { address } = useAccount();

  const { data: allowance } = useReadContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: "allowance",
    args: [address!, spenderAddress],
    query: { enabled: Boolean(address) },
  });

  const { writeContract: approve, isPending } = useWriteContract();

  function handleApprove() {
    approve({
      address: tokenAddress,
      abi: erc20Abi,
      functionName: "approve",
      args: [spenderAddress, maxUint256],
    });
  }

  return {
    allowance: allowance ?? 0n,
    needsApproval: (allowance ?? 0n) === 0n,
    approve: handleApprove,
    isApproving: isPending,
  };
}

Watching for contract events:

typescript
import { useWatchContractEvent } from "wagmi";

export function useTransferEvents(tokenAddress: `0x${string}`) {
  useWatchContractEvent({
    address: tokenAddress,
    abi: erc20Abi,
    eventName: "Transfer",
    onLogs(logs) {
      for (const log of logs) {
        console.log("Transfer:", log.args.from, "->", log.args.to, log.args.value);
      }
    },
  });
}

Estimating gas before sending:

typescript
import { useEstimateGas } from "wagmi";
import { encodeFunctionData } from "viem";

const { data: gasEstimate } = useEstimateGas({
  to: contractAddress,
  data: encodeFunctionData({
    abi: stakingAbi,
    functionName: "stake",
    args: [parseUnits("100", 18)],
  }),
});

These patterns cover probably 80% of what a typical dApp frontend needs. The remaining 20% is project-specific logic that sits on top of these primitives.


Migration from ethers.js

If you are migrating from ethers.js, here are the most common translations:

Provider to Client:

typescript
// ethers.js
const provider = new ethers.JsonRpcProvider(rpcUrl);

// viem
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({
  chain: mainnet,
  transport: http(rpcUrl),
});

Contract reads:

typescript
// ethers.js
const contract = new ethers.Contract(address, abi, provider);
const balance = await contract.balanceOf(userAddress);

// viem
const balance = await client.readContract({
  address,
  abi,
  functionName: "balanceOf",
  args: [userAddress],
});

BigNumber to BigInt:

typescript
// ethers.js
const amount = ethers.parseUnits("1.5", 18);
const formatted = ethers.formatUnits(balance, 18);

// viem
import { parseUnits, formatUnits } from "viem";
const amount = parseUnits("1.5", 18);
const formatted = formatUnits(balance, 18);

Signer to Wallet Client:

typescript
// ethers.js
const signer = await provider.getSigner();
const tx = await contract.connect(signer).transfer(to, amount);

// viem
import { createWalletClient, custom } from "viem";
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum!),
});
const hash = await walletClient.writeContract({
  address,
  abi,
  functionName: "transfer",
  args: [to, amount],
});

The mental model shift is: ethers.js is object-oriented (create a Contract instance, call methods on it), while viem is functional (pass configuration to functions). Once you internalize that shift, the API feels natural.

My recommendation for migration: do not try to swap everything at once. Start with new features using wagmi + viem, and gradually migrate existing code. Both libraries can coexist in the same project during the transition. I typically migrate one page at a time, starting with the most actively developed areas of the codebase.


Key Takeaways

  1. viem is not just smaller — it is fundamentally better. Tree-shaking, ABI-level type inference, and native BigInt support make it a generational improvement over ethers.js. The bundle size difference alone justifies the switch.
  1. wagmi eliminates boilerplate. Wallet connection, contract reads, transaction writes, chain switching, event watching — all handled through hooks that integrate cleanly with React's component model and lifecycle.
  1. RainbowKit is the fastest path to a polished wallet UX. Unless you have very specific design requirements, use it. Your users already recognize the interface, and it handles edge cases you have not thought of.
  1. TanStack Query integration is the real superpower. Caching, automatic refetching, query invalidation after writes, dependent queries — these are the patterns that make production dApps feel responsive rather than sluggish.
  1. Multi-chain is table stakes. wagmi's declarative chain configuration means supporting Arbitrum, Base, and Optimism alongside Ethereum mainnet is a config change, not an architecture change. Build for multi-chain from day one.
  1. Type safety catches bugs before users do. ABI-level TypeScript inference means misspelled function names, wrong argument types, and incorrect return type assumptions are all compile-time errors. This is worth the migration effort on its own.
  1. Migrate incrementally. ethers.js and viem can coexist. Start new features with wagmi + viem and migrate existing code one page at a time.

If you are building a dApp frontend in 2024, wagmi + viem is the stack. The developer experience is better, the bundle is smaller, the type safety is stronger, and the React integration is exactly what the ecosystem needed.


*I build production dApp frontends with wagmi, viem, and Next.js for clients shipping on Ethereum, Arbitrum, Base, and Optimism. If you need a Web3 frontend that is fast, type-safe, and ready for mainnet, let's talk.*


Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production-grade decentralized applications and smart contract systems. Follow the work at iamuvin.com and @IAMUVIN.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.