Web3 apps have unique architectural challenges: blockchain reads are slow, state is split between on-chain and off-chain, and user actions require wallet confirmation. Here's how to structure it correctly.
The Architecture Stack
Frontend (Next.js + wagmi)
|
βββ Smart Contracts (Ethereum/Polygon/Base)
| βββ Direct reads via RPC
|
βββ The Graph (indexed blockchain data)
| βββ Fast GraphQL queries over on-chain events
|
βββ Backend API (optional, for off-chain data)
βββ User profiles, metadata, notifications
Why You Need an Indexer (The Graph)
Reading data directly from contracts is slow and limited:
// Bad: reading 1000 events directly from chain is slow and expensive
const filter = contract.filters.Transfer(null, userAddress)
const events = await contract.queryFilter(filter, 0, 'latest') // Scans entire chain!
// Good: query indexed data via GraphQL
const { data } = useQuery(gql`
query UserTransfers($address: String!) {
transfers(where: { to: $address }, orderBy: blockTimestamp, orderDirection: desc) {
id
from
to
value
blockTimestamp
transactionHash
}
}
`, { variables: { address: userAddress.toLowerCase() } })
Setting Up a Subgraph
# subgraph.yaml
specVersion: 0.0.5
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum
name: Token
network: base
source:
address: "0x..."
abi: Token
startBlock: 12345678
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
entities:
- Transfer
- User
abis:
- name: Token
file: ./abis/Token.json
eventHandlers:
- event: Transfer(indexed address,indexed address,uint256)
handler: handleTransfer
file: ./src/mapping.ts
// src/mapping.ts
import { Transfer as TransferEvent } from '../generated/Token/Token'
import { Transfer, User } from '../generated/schema'
export function handleTransfer(event: TransferEvent): void {
let transfer = new Transfer(event.transaction.hash.toHex() + '-' + event.logIndex.toString())
transfer.from = event.params.from.toHex()
transfer.to = event.params.to.toHex()
transfer.value = event.params.value
transfer.blockTimestamp = event.block.timestamp
transfer.transactionHash = event.transaction.hash.toHex()
transfer.save()
// Update user balance
let user = User.load(event.params.to.toHex())
if (!user) {
user = new User(event.params.to.toHex())
user.totalReceived = event.params.value
} else {
user.totalReceived = user.totalReceived.plus(event.params.value)
}
user.save()
}
Frontend State Architecture
// Split on-chain and off-chain state clearly
// hooks/useUserDashboard.ts
// On-chain: read directly or via subgraph
function useOnChainData(address: string) {
const { data: balance } = useReadContract({
address: TOKEN_ADDRESS,
abi: tokenAbi,
functionName: 'balanceOf',
args: [address]
})
const { data: transfers } = useQuery(TRANSFERS_QUERY, {
variables: { address: address.toLowerCase() }
})
return { balance, transfers }
}
// Off-chain: fetch from your backend API
function useOffChainData(address: string) {
return useQuery({
queryKey: ['user-profile', address],
queryFn: () => fetch(`/api/users/${address}`).then(r => r.json())
})
}
// Combined hook
export function useUserDashboard(address: string) {
const onChain = useOnChainData(address)
const offChain = useOffChainData(address)
return { ...onChain, ...offChain }
}
Wallet-Based Authentication
// Authenticate users with their wallet signature (SIWE - Sign In With Ethereum)
import { SiweMessage } from 'siwe'
import { useSignMessage, useAccount } from 'wagmi'
export function useWalletAuth() {
const { address } = useAccount()
const { signMessageAsync } = useSignMessage()
const signIn = async () => {
const nonce = await fetch('/api/auth/nonce').then(r => r.text())
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to MyDApp',
uri: window.location.origin,
version: '1',
chainId: 1,
nonce
})
const signature = await signMessageAsync({
message: message.prepareMessage()
})
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature })
})
const { token } = await response.json()
// Store JWT for subsequent API calls
localStorage.setItem('auth_token', token)
}
return { signIn }
}
Handling Transaction UX
The hardest part of Web3 UX: transactions take 12-30 seconds and can fail:
type TxState = 'idle' | 'pending-wallet' | 'pending-chain' | 'success' | 'error'
function useTransactionFlow() {
const [state, setState] = useState<TxState>('idle')
const { writeContractAsync } = useWriteContract()
const { waitForTransactionReceipt } = useWaitForTransactionReceipt
const execute = async (contractCall: ContractCallConfig) => {
setState('pending-wallet')
let hash: `0x${string}`
try {
hash = await writeContractAsync(contractCall)
} catch (e) {
// User rejected in wallet
setState('idle')
return
}
setState('pending-chain')
try {
await waitForTransactionReceipt({ hash })
setState('success')
} catch (e) {
setState('error')
}
}
return { state, execute }
}
// In component
const { state, execute } = useTransactionFlow()
const messages = {
idle: 'Stake Tokens',
'pending-wallet': 'Confirm in MetaMask...',
'pending-chain': 'Transaction submitted, waiting...',
success: 'Staked successfully!',
error: 'Transaction failed'
}
Gas Estimation
import { estimateGas, formatEther } from 'viem'
import { usePublicClient } from 'wagmi'
function useGasEstimate(contractCall) {
const publicClient = usePublicClient()
return useQuery({
queryKey: ['gas-estimate', contractCall],
queryFn: async () => {
const gas = await publicClient.estimateContractGas(contractCall)
const gasPrice = await publicClient.getGasPrice()
const costWei = gas * gasPrice
return {
gas,
costEth: formatEther(costWei),
costUsd: Number(formatEther(costWei)) * ETH_PRICE_USD
}
}
})
}
The key insight for Web3 architecture: minimize what's on-chain (expensive, slow, immutable) and maximize what's off-chain (cheap, fast, flexible). Smart contracts enforce rules and hold assets. Everything else goes in your regular database.