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
- viem is the modern low-level TypeScript client; wagmi is the React hooks layer on top of it.
- Split clients: a public client for reads, a wallet client for signing. Reads are free; writes cost gas.
- Never handle a user's private key or seed phrase. The wallet signs; your app requests.
- Read from an indexer, not by scanning logs in the browser. The chain is not a query engine.
- Simulate before sending — catch reverts before the user pays for them.
- Transactions have a lifecycle (
idle → signing → pending → confirmed | reverted) and the UI must show all of it. - EIP-712 typed signing shows users a readable structure instead of an opaque hex blob.
- Handle wrong-network, rejected-signature, and insufficient-gas as first-class states, not error toasts.
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).maxis 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 preferpermitwhere 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
- Ethereum & the EVM — Accounts, gas, and account abstraction
- Solidity — The contracts your frontend calls
- Smart Contract Security — Slippage, approvals, and what users are signing
- Layer 2 Scaling — Where fees make good UX affordable
- React — The framework wagmi is built for
- TypeScript — viem's type inference is a major reason to use it
- TanStack Query — The caching layer under wagmi
- Authentication — Sessions after Sign-In With Ethereum
- GraphQL — Querying subgraphs