back

Rick ·

How do I connect wallets with Wagmi and Viem in Next.js?

Dev tooling

A connect button is genuinely easy. Install Wagmi, Viem and TanStack Query, wrap the tree in three providers, render a button per connector, and you are done before lunch.

Then the page gets server-rendered and flickers. Then a reload logs the user out, or does not, and you cannot tell which is correct. Then someone switches chains in their wallet and your page keeps showing numbers from the chain they left. Then a transaction says "confirmed" and the number on screen does not move.

None of that is exotic. All of it is the part the quickstart cannot show you, because all of it depends on state that lives outside React — in the wallet, in the browser's storage, and on a chain.

So I built one Next.js app in four Wagmi configurations, gave it an EIP-1193 wallet I can script, pointed it at three local chains, and drove the whole thing in Chrome. Everything below is a measurement from that harness, not a recollection.

Versions, because all of this moves: Wagmi 3.7.7, Viem 2.56.3, TanStack Query 5.102.8, Next.js 16.3.4 with the App Router, React 19.2.8, Foundry 1.5.1, Chrome 152.0.7977.76, on an Apple M3 Pro. The project is in examples/articles/how-do-i-connect-wallets-with-wagmi-and-viem-in-next-js/.

The harness

One Solidity contract, Roster: each address may claim exactly one numbered seat, and claiming twice reverts with AlreadyClaimed(address,uint256). It is small on purpose, but it has three states a wallet UI has to handle — a read that changes for everyone when anyone writes, a read that depends on which account is connected, and a revert that depends on chain state rather than on the arguments you passed.

Three chains, all Anvil: chain 31337 with an empty roster, chain 31338 with three seats already taken, and a third node that really is chain id 1, so "the wallet is on mainnet" is a real chain id rather than a relabelled test node.

The wallet is 165 lines of JavaScript injected before any app code runs. It answers eth_requestAccounts, eth_accounts, eth_chainId and wallet_switchEthereumChain, forwards everything else to whichever chain it is currently pointed at, emits chainChanged, remembers across reloads whether the user has approved the site, and can be told to reject the next request the way a person would. It does not simulate transactions before sending them, which is the one place it is less helpful than a real extension; I flag that where it matters.

The page renders every hook's state onto data- attributes, and a MutationObserver installed before hydration records a snapshot on every change. That is what makes the state sequences below observable rather than inferred.

What the server can render, and what it cannot

Wagmi's SSR guide opens with a warning: because Wagmi reads "client-only external stores (such as localStorage and mipd)", frameworks that server-render "will throw hydration warnings on the client when it identifies mismatches". The fix is ssr: true, and then a second step — cookie storage — to avoid "a flash of 'empty' data".

Both are worth doing. Neither does quite what that paragraph implies.

I built the app four ways and left everything else identical. no-ssr is Wagmi's defaults; ssr turns the flag on and keeps localStorage; cookies adds cookieStorage and cookieToInitialState; no-reconnect is cookies with reconnectOnMount={false} on the provider.

export function getConfig(variant: Variant) {
  const usesCookies = variant === "cookies" || variant === "no-reconnect";
  return createConfig({
    chains: [anvilA, anvilB, sepolia],
    connectors: [injected()],
    ssr: variant !== "no-ssr",
    // Omitting `storage` leaves wagmi's default, which is localStorage.
    storage: usesCookies ? createStorage({ storage: cookieStorage }) : undefined,
    transports: {
      [anvilA.id]: http(),
      [anvilB.id]: http(),
      // Configured so the app can ask to switch to it; never actually called.
      [sepolia.id]: http("http://127.0.0.1:1"),
    },
  });
}

With a single statically configured connector, none of the four produced a hydration warning. Not even the one with ssr off. That is not what the guide led me to expect, so I went looking for why, and the answer is one line in @wagmi/core's createConfig: immediately after building the store with the persistence middleware, it calls store.setState(getInitialState()). The persisted state is wiped on creation, so the browser's first render starts from the same blank state the server did. Reconnection does not depend on that store at all — it works off a separate recentConnectorId key plus whatever the wallet answers to eth_accounts.

Then I made the wallet announce itself over EIP-6963, the way the standard asks wallets to, and the warning appeared immediately — in exactly one of the eight cells:

Configuration Connectors, server Connectors, browser Console
ssr: false, no EIP-6963 wallet injected injected clean
ssr: false, EIP-6963 wallet injected injected, build.rick.harness React error #418
ssr: true, EIP-6963 wallet injected injected, build.rick.harness clean

React error #418 is "Hydration failed because the server rendered text didn't match the client". The mismatch is not the connection status. It is the connector list: mipd discovers wallets by listening for an event that only fires in a browser, so the server rendered one button and the client rendered two. ssr: true defers that discovery until after the first mount, and the mismatch disappears.

That reframes the setting. ssr: true is not a general safety switch for connection state — it is specifically what stops browser-discovered wallets from changing the tree during hydration. You will not reproduce the bug with a hardcoded connector list and a headless test. Your users, with a wallet extension installed, will.

One more thing falls out of ssr: false that is worth knowing. With it off, Wagmi's hydrate calls onMount() during render — including on the server — which runs reconnect(), which sets the status to 'connecting' because there is no current connection. So the server ships HTML that says a connection attempt is in progress, on a machine that has no wallet and never will. It is harmless here, and it stayed deterministic across 24 concurrent requests, but it is not something you want in your server's render path.

Cookies do not make the reconnect faster

The second half of the SSR guide is cookie storage plus cookieToInitialState, so the server can render what the browser already knows:

  // Only the cookie variants have anything to read: with localStorage the server
  // has no way to know what the browser stored.
  const initialState =
    variant === "cookies" || variant === "no-reconnect"
      ? cookieToInitialState(getConfig(variant), (await headers()).get("cookie"))
      : undefined;

I connected a wallet in each configuration and then reloaded the page seven times, recording what the server sent, what the browser painted first, and how long it took to settle.

State grid of four Wagmi configurations after a connected visitor reloads the page. no-ssr (wagmi defaults): served HTML connecting, first client frame connecting, settles to connected after a median of 20 milliseconds. ssr (ssr: true): served HTML disconnected, first client frame disconnected, settles to connected after a median of 21 milliseconds. cookies (ssr + cookieStorage): served HTML reconnecting, first client frame reconnecting, settles to connected after a median of 21 milliseconds. no-reconnect (+ reconnectOnMount false): served HTML disconnected, first client frame disconnected, and it never reaches connected.

Median settle times: 20, 21 and 21 milliseconds, with every one of the 21 individual samples between 19 and 23. On a Slow 4G profile with a 4x CPU slowdown, 70 and 71 milliseconds for the ssr and cookies builds. Cookie storage did not shorten the window by a measurable amount, in either condition.

What it changes is the word in the window. Without cookies the server sends disconnected — indistinguishable from a genuinely logged-out visitor, so a component written as if (!isConnected) return <ConnectButton /> renders a connect button in the server HTML and again on the first client frame, then swaps it. With cookies the server sends reconnecting, along with the address it expects, and you can render the skeleton that is about to be correct. The cookie also lets the server render account-dependent layout without waiting for the client at all: in the cookies build the served HTML contained 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 before a single line of JavaScript ran.

So the reason to use cookie storage is not speed. It is that 'reconnecting' and 'disconnected' mean different things to a user, and only the cookie build can tell them apart before hydration.

What the server never has is chain data. In all four builds the served HTML contained no value for the contract read: useReadContract is a TanStack Query hook that runs in the browser, so unless you prefetch and dehydrate the query yourself, the first paint has a blank where the number goes. Cookies fix the wallet flash, not the data flash.

Reconnecting is the wallet's decision

Three keys get written when a connection succeeds — wagmi.store, wagmi.recentConnectorId and wagmi.injected.connected — to localStorage or to cookies depending on the configuration. It is tempting to read that as the session.

It is not. I called wallet_revokePermissions on the wallet, leaving every one of those keys in place, and reloaded. Every configuration went to disconnected. Wagmi asks the connector isAuthorized(), the connector asks the wallet eth_accounts, and a wallet that has forgotten your site returns an empty array. Your storage is a hint about which connector to try, not a record that the user is connected.

reconnectOnMount is the switch that decides whether the attempt happens at all. It defaults to true. Setting it to false gives you the fourth row above: the cookie is present, the server still renders disconnected rather than reconnecting, and the page never reaches connected without a click. Wagmi's own hydrate makes both of those explicit:

            connections: reconnectOnMount ? initialState.connections : new Map(),
            status: reconnectOnMount ? 'reconnecting' : 'disconnected',

That is a defensible choice for an app where connecting should be a deliberate act. It is not what most people want by default, and it is easy to set while debugging and forget.

Five ways the chain changes underneath you

Chain switching is where the gap between "the wallet's chain" and "the chain your app talks to" opens up, and Wagmi gives you both without making the difference obvious. useConnection().chainId is where the wallet is. useChainId() is where every read and write goes. Here is what each of five events did to them:

What happened Wallet RPC calls useConnection().chainId useChainId() useSwitchChain().status Page read
App asks for a chain the wallet knows wallet_switchEthereumChain, eth_chainId 31338 31338 idle → pending → success 0 → 3, refetched
App asks for a chain the wallet does not know wallet_switchEthereumChain, wallet_addEthereumChain, eth_chainId 11155111 11155111 idle → pending → success blank, still pending
User rejects the prompt wallet_switchEthereumChain 31337 31337 idle → error unchanged
User switches inside the wallet none 31338 31338 idle 0 → 3, refetched
Wallet moves to a chain the app never configured none 1 31337 idle unchanged, and wrong

Four things in that table are worth pulling out.

Reads follow the chain on their own. Wagmi's query keys include the chain id, so a switch invalidates every read by construction. The page went from 0 seats to 3 without any code of mine noticing. That is the one part of chain switching you do not have to think about.

A failed switch is silently upgraded into an add. Row two is the interesting one. My wallet rejected the unknown chain with EIP-1193 error 4902, and Wagmi's injected connector caught it and followed up with wallet_addEthereumChain — so the mutation reports success, and the user's wallet now contains a chain your app added. What it registers is the RPC URL from the Viem chain object, not your transport:

                        else
                            rpcUrls = [chain.rpcUrls.default?.http[0] ?? ''];

For Viem's built-in sepolia that is https://11155111.rpc.thirdweb.com, and that is exactly what my wallet received. Harmless for a public testnet. Considerably less harmless if you defined your own chain with defineChain and put a keyed provider URL in rpcUrls.default.http[0], because that key is now written into every user's wallet. Pass addEthereumChainParameter with a public URL if you configure custom chains.

"Switch succeeded" does not mean the app works there. Also in row two: the switch reported success, and the page's read was still pending when I stopped watching, because the transport I configured for Sepolia points at a port with nothing behind it. Wagmi is reporting on the wallet, not on your infrastructure.

Nothing tells you the wallet has wandered off. The last row is the dangerous one. The wallet reports chain 1, useConnection().chain becomes undefined because chain 1 is not in the config, and useChainId() stays at 31337 — so every read keeps hitting the old chain and keeps succeeding. The page showed 4 seats, cheerfully, while the wallet was somewhere else entirely. No error, no warning, no state change anywhere in the UI.

Wagmi's FAQ describes the intended behaviour precisely: the Config "listens for connection changes and updates the chainId based on the current connection". It does, for chains you configured. For anything else the two values simply part company, and the check for that is three lines:

  // `useConnection().chainId` is where the wallet is. `useChainId()` is where
  // every read and write goes. They are allowed to disagree, and nothing in
  // wagmi stops a write while they do.
  const onWrongChain = connection.isConnected && connection.chainId !== configChainId;

Why it matters is in the next section.

"Confirmed" is not the same as "your page is right"

The write-to-contract guide builds up the standard pattern in eight steps: useWriteContract for the mutation, isPending for the button, useWaitForTransactionReceipt for isConfirming and isConfirmed, and a try/catch for errors. It ends by rendering "Transaction confirmed."

I built exactly that, clicked the button, and recorded every state the page passed through.

Flow diagram of five measured endings for one click of the claim button in the Wagmi write path. user rejects: write.status idle → error; error.name ContractFunctionExecutionError; the real cause is 3 levels down. mined, succeeded — nothing invalidated: receipt.status pending → success;

The happy path works: write.status goes idle → pending → success, receipt.status goes pending → success, and "Transaction confirmed." appears. And the seat count on the page stayed at 0 for the 20 seconds I watched it, while the chain said 1.

Nothing is broken. useReadContract is a TanStack Query hook, its data is not stale in Query's sense, and no event tells it otherwise. The write and the read do not know about each other. The write-to-contract guide never mentions invalidation, and neither does the useWriteContract or useWaitForTransactionReceipt reference page — it lives in the TanStack Query guide, a page you have no particular reason to read while you are wiring up a button.

The fix is the effect that page describes, applied to the hashes you actually confirmed:

  const confirmedHash = receipt.data?.transactionHash;
  useEffect(() => {
    if (!invalidateAfterReceipt || !confirmedHash) return;
    queryClient.invalidateQueries({ queryKey: claimed.queryKey });
    queryClient.invalidateQueries({ queryKey: seat.queryKey });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [invalidateAfterReceipt, confirmedHash]);

With it, the same click gives 1 → 2 on the seat count and the user's own seat appears, in the same handful of milliseconds as the receipt. Without it, the page is wrong until something else happens to refetch. In a real browser that "something else" is usually the window regaining focus after the wallet popup closes, which is why this bug is so easy to miss in development and so visible to users who click and then wait.

The same staleness applies to useSimulateContract, and there it is worse than cosmetic. After a successful claim, the simulation for a second claim — which must now revert — still reported status: "success", and the button guarded by it stayed enabled. Reload the page and the fresh simulation correctly reports error with ContractFunctionExecutionError, and the button disables itself. A pre-flight check you never invalidate is a pre-flight check for a state that no longer exists.

The other four endings

A rejected signature does not look like a rejection. write.error.name is ContractFunctionExecutionError. The name you actually want is three levels down the cause chain: ContractFunctionExecutionError → TransactionExecutionError → UserRejectedRequestError → Error. If you branch on error.name, as Wagmi's error-handling guide suggests, you will treat "the user changed their mind" as a contract failure and show them something alarming. Walk the chain, or use Viem's BaseError.walk.

A reverted transaction errors rather than resolves — and loses the error name. This one surprised me, because Viem's waitForTransactionReceipt resolves with the receipt whatever its status. Wagmi's action wraps it and re-runs the transaction with call to recover a reason:

    if (receipt.status === 'reverted') {
        throw await withTimeout(async () => {

So receipt.status goes pending → error, which is better than the alternative — you cannot mistake a reverted transaction for a successful one. But the error you get is a Viem CallExecutionError whose message is Execution reverted with reason: custom error 0xe64ea1da: 00000000000000000000000015d34aaf…0003. The selector and the ABI-encoded arguments are right there, the ABI is right there, and nothing decodes them into AlreadyClaimed(0x15d3…, 3). Viem decodes the same revert perfectly well elsewhere: on the simulation path, error.walk finds a ContractFunctionRevertedError whose data.errorName is AlreadyClaimed. Walking the receipt error the same way finds nothing. If your contract defines custom errors, decode this yourself or your users see a hex blob.

It also took its time: the receipt error arrived 7,049 ms after the write on a chain that mines instantly, because the recovery call runs behind Viem's default 4,000 ms polling interval. Worth setting pollingInterval on local-chain clients before you conclude something is hung.

A write while the wallet is elsewhere goes somewhere else. This is the one that would ruin a real user's day. With the wallet on chain 1 and the app configured for 31337, I clicked claim. write.status went idle → pending → success. No error. The seat count on chain 1 went from 0 to 1; Anvil A stayed at 4; the page kept showing 4; and receipt.status was still pending when the harness stopped watching, because Wagmi looked for that hash on chain 31337, where it does not exist — with timeout defaulting to 0, that query has nothing to find and no deadline. The user's transaction was mined, on the wrong chain, and the interface reported nothing at all.

Adding the three-line onWrongChain check from the previous section and disabling the button on it fixes exactly that: with the guard on, the same sequence left both chains untouched and the page read Your wallet is on chain 1. This app talks to chain 31337.

Passing chainId to the mutation is the other half of the answer, and it does something different from what I assumed: it does not switch the wallet, it refuses to send. Wagmi sets Viem's assertChainId, and the same click failed with ContractFunctionExecutionError → TransactionExecutionError → ChainMismatchError, two eth_chainId calls and nothing broadcast on either chain. That is the safety net; the guard is what tells the user why the button is dead.

What I would actually write

Five things, in the order I would add them.

ssr: true, always, in a Next.js app. It costs nothing and it is the difference between a clean console and a hydration error for every user with a wallet extension installed.

Cookie storage, if the connected state affects layout above the fold. Not for speed — it is worth about zero milliseconds — but because rendering reconnecting instead of disconnected is the difference between a skeleton and a wrong button.

An invalidation effect keyed on the confirmed transaction hash. It is six lines and it is the difference between a page that agrees with the chain and one that quietly does not.

A chain guard comparing useConnection().chainId with useChainId(), wired to whatever your write button is. Everything else on this list is polish. This one prevents a transaction landing on the wrong chain with no indication in the UI.

An error helper that walks the cause chain before it decides what to show. error.name at the top level is almost never the thing that went wrong.

None of these are exotic. Four of the five exist because Wagmi's state and TanStack Query's cache and the wallet's own state are three separate systems, and Wagmi only synchronises the pairs it can see.

What this does not show

The wallet is mine. It answers the EIP-1193 methods faithfully and it persists authorization across reloads, but a real extension adds an approval UI, its own simulation, its own chain list, and its own opinions about what to do with wallet_addEthereumChain. Everything above about Wagmi's behaviour holds regardless; anything about what a user sees in the popup does not come from this harness.

The chains are local. There is no mempool, no reorg, no replacement transaction and no RPC latency, so nothing here tests onReplaced, confirmation depth, or what these state machines do when a node is slow. useWaitForTransactionReceipt takes a confirmations parameter that I never exercised beyond its default.

One connector. injected() plus one EIP-6963 announcement is enough to reproduce the hydration behaviour, and nothing about WalletConnect, Coinbase Wallet or a Safe app was tested; those connectors have their own reconnection stories, and WalletConnect in particular has a session lifetime that has nothing to do with anything measured here.

And the timings are one machine, one browser, seven cycles each. The one-millisecond gap between configurations is not a result; it is the absence of one, which is the point I was making with it.