Web3 Frontends

A Web3 frontend is an ordinary React application with two unusual dependencies: a wallet that holds keys the application never sees, and a chain that is slow to write and awkward to read. Everything distinctive about building one follows from those two facts.

The wallet inverts the normal authentication model. There's no signup, no session, no password — the user proves control of an address by signing, and your application's job is to make what they're signing legible. The chain inverts the normal read model. Writes take seconds to minutes, can fail after you've accepted them, and cost money; reads are technically possible but far too slow to drive a UI, which is why every serious application reads from an indexer and treats the chain as a write target and a source of truth of last resort.

TL;DR

Quick Example

A complete token transfer flow with wagmi:

Three things to notice: reads and writes use entirely different hooks with different cost models, the amount is scaled by the token's own decimals rather than a hardcoded 18, and every stage between "user clicked" and "on-chain" has a visible state.

Core Concepts

The client split

Reads never involve the wallet and never cost anything. Writes always require a user signature and always cost gas. Conflating them — for instance putting a read behind a "connect wallet" gate — is a common and unnecessary friction point: a dapp should display chain data to visitors who haven't connected anything.

Wallet connection

EIP-6963 solved the old problem of multiple browser wallets fighting over window.ethereum: wallets now announce themselves, and the user picks. Use a connector library that implements it rather than reading window.ethereum directly.

For the connect UI itself, RainbowKit, ConnectKit, or Reown AppKit handle wallet detection, chain switching, and the mobile deep-link flows — all of which are more fiddly than they look.

Reading state at scale

The chain is not a database. It has no indexes, no joins, and no aggregate queries, and getLogs over a wide block range will be rate-limited or time out.

The architecture that works: an indexer follows events into Postgres, your API serves the UI from it, and the frontend touches the chain only for values that must be current at the moment of signing. See Change Data Capture for the same pattern in a non-blockchain context.

Signing

Three distinct things get signed, and users deserve to know which is which:

Prefer EIP-712 to raw message signing for anything structured. The wallet renders the fields as a readable table rather than a hex string, which is the difference between an informed approval and a blind one. Sign-In With Ethereum (EIP-4361) standardizes the authentication message, including a domain and nonce so a signature can't be replayed against another site.

Transaction lifecycle

Every one of these needs a UI state. Applications that show only "loading" and "done" leave users staring at a spinner with no idea whether to wait, retry, or check their wallet — and users who don't know will often submit a second transaction.

Always link to a block explorer as soon as you have a hash. It's the cheapest possible trust-building feature.

Transaction UX

Simulate first

Simulation is the single highest-value UX improvement in a Web3 frontend. It turns "your transaction failed and you paid $4 for the privilege" into "you can't withdraw more than your balance" — before the wallet prompt opens.

Approve-then-act

The classic two-transaction flow: approve the contract to spend your tokens, then act.

Better options where available: EIP-2612 permit replaces the approval transaction with a free signature, and ERC-4337 batching lets approve-and-act happen in one user confirmation. When you must show two transactions, label them as steps.

⚠️ Warning: Approving type(uint256).max is convenient and means the contract can move all of that token, forever. It's a standard practice with a real risk — if the approved contract is later compromised, the allowance is the attack path. Offer an exact-amount option and prefer permit where the token supports it.

Error handling

The raw errors are unreadable. Map them:

A user rejecting a signature is not an error — it's a choice, and it should not produce a red banner.

Best Practices

Never ask for a private key or seed phrase

There is no legitimate reason for a web application to receive one. The wallet holds the key and produces signatures; your application receives signatures. Any UI that requests a seed phrase is either a phishing site or is about to become one.

Show reads without requiring a connection

Prices, pools, leaderboards, and public state should render for visitors who haven't connected anything. Gating all content behind "connect wallet" is a conversion problem with no technical justification, since reads don't need a wallet.

Always read decimals from the token

ETH has 18, USDC has 6, WBTC has 8. Hardcoding 18 produces off-by-a-trillion errors, and they're not always caught in testing because test tokens usually use 18.

Use bigint throughout and format only at the edges

viem returns bigint for all on-chain numbers. Converting to number loses precision above 2⁵³ — which token amounts routinely exceed. Keep values as bigint through your logic and call formatUnits only at the render boundary.

Handle the wrong-network state explicitly

Users will arrive on the wrong chain constantly. Detect it, show a clear prompt with a one-click switch, and disable actions rather than letting them fail against a contract that doesn't exist at that address on that chain.

Configure RPC fallbacks

RPC providers rate-limit and have outages. Configure a fallback transport, and never ship a public endpoint as your only provider — public endpoints are aggressively throttled and shared with everyone.

Cache aggressively and invalidate on confirmation

wagmi builds on TanStack Query, so you get caching and background refetching for free. Set sensible staleTime values (chain state doesn't change every render), and invalidate the specific queries a confirmed transaction affects rather than refetching everything.

Explain what a signature does before requesting it

Users have been trained — correctly — to be suspicious of signature prompts. A sentence explaining what they're approving and why, shown before the wallet opens, reduces both abandonment and the risk of them approving something malicious elsewhere out of habit.

Common Mistakes

Reading chain history in the browser

Hardcoding decimals

Converting bigint to number

Treating one confirmation as done

Sending without simulating

Treating a rejected signature as a failure

FAQ

viem/wagmi or ethers.js?

viem and wagmi for new projects. viem has better TypeScript inference (ABI types flow through to function arguments and return values), a smaller bundle, and a more predictable API; wagmi gives you React hooks with caching built on TanStack Query. ethers.js is mature and extremely widely deployed — fine to maintain, and rarely the choice for greenfield work today.

How do I authenticate users?

Sign-In With Ethereum (EIP-4361): your server issues a nonce, the user signs a structured message containing it and your domain, and your server verifies the signature and issues a normal session. The address becomes the user identifier. Note that a signature proves control of an address at a moment in time — you still need standard session management, and you must include the domain and nonce or the signature is replayable.

Do users need to pay gas?

Not necessarily. Paymasters under ERC-4337 let your application sponsor gas or accept payment in any token, and permit removes the approval transaction entirely. On an L2, fees are low enough that sponsoring a new user's first few transactions is often affordable — which is the single biggest onboarding improvement available.

How do I support multiple chains?

Configure all chains in your wagmi config, detect the connected chain, and prompt to switch when it doesn't match what the current action requires. Keep contract addresses in a per-chain map rather than a single constant, and deploy with CREATE2 for identical addresses across chains where possible. Assume users will arrive on the wrong chain — the flow for handling it is a main path, not an edge case.

Should I run my own RPC node?

Almost certainly not. Use a provider (Alchemy, Infura, QuickNode) with a fallback configured. Run your own only for censorship resistance, query privacy, archive data, or volume where hosting is cheaper than provider fees — and budget for the operational load, since an archive node is a large, demanding machine.

How do I test a Web3 frontend?

Fork mainnet locally with Anvil, which gives you real contract state, funded accounts, and instant blocks. Use a test wallet with a known private key for automated flows, or wagmi's mock connector to bypass the wallet entirely in component tests. Playwright with a browser extension wallet works for end-to-end coverage but is slow and brittle — keep those tests few and focused on the critical path.

Related Topics

References