This guide walks you through building an onchain tally app on Base from scratch. You will connect wallets, read and write to a smart contract, detect wallet capabilities, and fall back gracefully for wallets that do not support batching.
What you’ll build
- A Next.js app that connects wallets and handles connection state
- Contract reads and writes against a deployed counter on Base Sepolia
- Batch transaction support for smart wallets via EIP-5792
- A graceful fallback for wallets that do not support batching
Base is a fast, low-cost Ethereum L2 built to bring the next billion users onchain. Low gas fees make batch transactions practical and real-time UX possible. Every pattern in this guide works on any EVM chain.
Steps
Set up your project
Create a new Next.js app and install the required dependencies.npx create-next-app@latest my-base-app --typescript --tailwind --app
cd my-base-app
npm install wagmi viem @tanstack/react-query @base-org/account
Configure Wagmi for Base
Create the Wagmi config with Base Sepolia, then wrap your app in the required providers.import { http, createConfig, createStorage, cookieStorage } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { baseAccount, injected } from 'wagmi/connectors'
export const config = createConfig({
chains: [baseSepolia],
connectors: [
injected(),
baseAccount({
appName: 'My Base App',
}),
],
storage: createStorage({ storage: cookieStorage }),
ssr: true,
transports: {
[baseSepolia.id]: http('https://sepolia.base.org'),
},
})
declare module 'wagmi' {
interface Register {
config: typeof config
}
}
ssr: true combined with cookieStorage prevents Next.js hydration mismatches. The baseAccount connector connects users via the Base Account SDK smart wallet — you will detect its capabilities in step 7. The injected connector handles browser extension wallets like MetaMask.'use client'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode } from 'react'
import { config } from '@/config/wagmi'
const queryClient = new QueryClient()
export function Providers({ children }: { children: ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
)
}
Wrap your root layout with <Providers>. Connect wallets
Create a component that handles all four wallet connection states.components/ConnectWallet.tsx
'use client'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
export function ConnectWallet() {
const { address, isConnected, isConnecting, isReconnecting } = useAccount()
const { connect, connectors } = useConnect()
const { disconnect } = useDisconnect()
if (isReconnecting) return <div>Reconnecting...</div>
if (!isConnected) {
return (
<div className="flex flex-col gap-2">
{connectors.map((connector) => (
<button
key={connector.uid}
onClick={() => connect({ connector })}
disabled={isConnecting}
>
Connect {connector.name}
</button>
))}
</div>
)
}
return (
<div className="flex items-center gap-3">
<span className="font-mono text-sm">
{address?.slice(0, 6)}...{address?.slice(-4)}
</span>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
)
}
useAccount exposes four states: isConnecting, isReconnecting, isConnected, and isDisconnected. Checking only isConnected causes UI flashes on page load — handle all four.
Deploy a contract with Foundry
Install Foundry and initialize a contracts directory inside your project.mkdir contracts && cd contracts
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init --no-git
The --no-git flag prevents Foundry from initialising a nested git repository inside your project.
Configure Base Sepolia in your environment file.BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
If https://sepolia.base.org is unreachable, use an alternative public endpoint such as https://base-sepolia-rpc.publicnode.com. For production apps, use a dedicated RPC provider.
Load the variable and import your deployer key securely.source .env
cast wallet import deployer --interactive
Never share or commit your private key. cast wallet import stores it in ~/.foundry/keystores, which is not tracked by git.
cast wallet import --interactive requires a TTY (interactive terminal). In scripted or CI environments, pass the key directly instead:forge create ./src/Counter.sol:Counter \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
Deploy the contract.forge create ./src/Counter.sol:Counter \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--account deployer
Verify the deployment by reading the initial counter value.cast call <CONTRACT_ADDRESS> "number()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
You need testnet ETH to pay for deployment. Get free Base Sepolia ETH from one of the network faucets. Read contract data
Define your contract address and ABI, then read the current counter value.export const COUNTER_ADDRESS = '0x...' as const
export const counterAbi = [
{
type: 'function',
name: 'number',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'increment',
inputs: [],
outputs: [],
stateMutability: 'nonpayable',
},
] as const
as const is required. Without it, wagmi cannot infer function names, argument types, or return types from the ABI.
components/CounterDisplay.tsx
'use client'
import { useReadContract } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'
export function CounterDisplay() {
const { data: count, isLoading, isError } = useReadContract({
address: COUNTER_ADDRESS,
abi: counterAbi,
functionName: 'number',
chainId: baseSepolia.id,
})
if (isLoading && count === undefined) return <p>Loading...</p>
if (isError && count === undefined) return <p>Failed to read contract</p>
return <p className="text-5xl font-bold">{count?.toString()}</p>
}
isError can be true while data still holds a valid cached value from a previous successful fetch. Always gate error renders on data === undefined so stale data is preferred over an error message.
Write to a contract
Send a transaction and surface all three confirmation states to the user.components/IncrementButton.tsx
'use client'
import { useEffect } from 'react'
import {
useWriteContract,
useWaitForTransactionReceipt,
useChainId,
useSwitchChain,
} from 'wagmi'
import { readContractQueryOptions } from 'wagmi/query'
import { useQueryClient } from '@tanstack/react-query'
import { baseSepolia } from 'wagmi/chains'
import { config } from '@/config/wagmi'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'
export function IncrementButton() {
const chainId = useChainId()
const { switchChain, isPending: isSwitching } = useSwitchChain()
const { data: hash, isPending, writeContract } = useWriteContract()
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash })
const queryClient = useQueryClient()
useEffect(() => {
if (isSuccess) {
queryClient.invalidateQueries({
queryKey: readContractQueryOptions(config, {
address: COUNTER_ADDRESS,
abi: counterAbi,
functionName: 'number',
chainId: baseSepolia.id,
}).queryKey,
})
}
}, [isSuccess, queryClient])
if (chainId !== baseSepolia.id) {
return (
<button onClick={() => switchChain({ chainId: baseSepolia.id })}>
{isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
</button>
)
}
return (
<div>
<button
onClick={() =>
writeContract({
address: COUNTER_ADDRESS,
abi: counterAbi,
functionName: 'increment',
chainId: baseSepolia.id,
})
}
disabled={isPending || isConfirming}
>
{isPending
? 'Confirm in Wallet...'
: isConfirming
? 'Confirming...'
: 'Increment'}
</button>
{isSuccess && <p>Confirmed!</p>}
{hash && (
<a href={`https://sepolia.basescan.org/tx/${hash}`} target="_blank">
View on Basescan
</a>
)}
</div>
)
}
useReadContract caches its result and does not automatically refetch after a write. Use queryClient.invalidateQueries with the read’s query key to trigger a single refetch when a transaction confirms.
Surface three states to the user: waiting for wallet signature, waiting for on-chain confirmation, and success.Without useSwitchChain, calling writeContract while the wallet is on the wrong network causes wagmi to attempt a background chain switch. If the user misses or dismisses the wallet popup, the button stays at “Confirm in Wallet…” indefinitely with no error and no recovery path.
Detect wallet capabilities
Smart wallets support batch transactions via EIP-5792. EOAs do not. Detect support before attempting to batch.hooks/useWalletCapabilities.ts
import { useCapabilities } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { useMemo } from 'react'
export function useWalletCapabilities() {
const { data: capabilities } = useCapabilities()
const supportsBatching = useMemo(() => {
const atomic = capabilities?.[baseSepolia.id]?.atomic
return atomic?.status === 'ready' || atomic?.status === 'supported'
}, [capabilities])
const supportsPaymaster = useMemo(() => {
return capabilities?.[baseSepolia.id]?.paymasterService?.supported === true
}, [capabilities])
return { supportsBatching, supportsPaymaster }
}
useChainId() returns the wallet’s current chain, not your deployment chain. A MetaMask user on Ethereum mainnet would get incorrect capability results. Always check capabilities against the chain where your contract is deployed.
See Batch Transactions with Wagmi for a deeper look at EIP-5792 capability detection. Batch transactions with fallback
Use useSendCalls for smart wallets and useWriteContract for EOAs. The component detects which path to take at render time.components/BatchIncrement.tsx
'use client'
import { useEffect } from 'react'
import {
useSendCalls,
useWaitForCallsStatus,
useWriteContract,
useWaitForTransactionReceipt,
useAccount,
useChainId,
useSwitchChain,
} from 'wagmi'
import { readContractQueryOptions } from 'wagmi/query'
import { useQueryClient } from '@tanstack/react-query'
import { encodeFunctionData } from 'viem'
import { baseSepolia } from 'wagmi/chains'
import { config } from '@/config/wagmi'
import { useWalletCapabilities } from '@/hooks/useWalletCapabilities'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'
const counterQueryKey = readContractQueryOptions(config, {
address: COUNTER_ADDRESS,
abi: counterAbi,
functionName: 'number',
chainId: baseSepolia.id,
}).queryKey
export function BatchIncrement() {
const { isConnected } = useAccount()
const { supportsBatching } = useWalletCapabilities()
if (!isConnected) return <p>Connect your wallet first.</p>
return supportsBatching ? <BatchFlow /> : <SequentialFlow />
}
function BatchFlow() {
const chainId = useChainId()
const { switchChain, isPending: isSwitching } = useSwitchChain()
const { data, sendCalls, isPending } = useSendCalls()
const { isLoading: isConfirming, isSuccess } = useWaitForCallsStatus({
id: data?.id,
})
const queryClient = useQueryClient()
useEffect(() => {
if (isSuccess) {
queryClient.invalidateQueries({ queryKey: counterQueryKey })
}
}, [isSuccess, queryClient])
if (chainId !== baseSepolia.id) {
return (
<button onClick={() => switchChain({ chainId: baseSepolia.id })}>
{isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
</button>
)
}
const incrementData = encodeFunctionData({
abi: counterAbi,
functionName: 'increment',
})
return (
<div>
<button
onClick={() =>
sendCalls({
calls: [
{ to: COUNTER_ADDRESS, data: incrementData },
{ to: COUNTER_ADDRESS, data: incrementData },
],
chainId: baseSepolia.id,
})
}
disabled={isPending || isConfirming}
>
{isPending
? 'Confirm in Wallet...'
: isConfirming
? 'Confirming...'
: 'Increment x2 (Batch)'}
</button>
{isSuccess && <p>Batch confirmed!</p>}
</div>
)
}
function SequentialFlow() {
const chainId = useChainId()
const { switchChain, isPending: isSwitching } = useSwitchChain()
const { data: hash, isPending, writeContract } = useWriteContract()
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash })
const queryClient = useQueryClient()
useEffect(() => {
if (isSuccess) {
queryClient.invalidateQueries({ queryKey: counterQueryKey })
}
}, [isSuccess, queryClient])
if (chainId !== baseSepolia.id) {
return (
<button onClick={() => switchChain({ chainId: baseSepolia.id })}>
{isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
</button>
)
}
return (
<button
onClick={() =>
writeContract({
address: COUNTER_ADDRESS,
abi: counterAbi,
functionName: 'increment',
chainId: baseSepolia.id,
})
}
disabled={isPending || isConfirming}
>
{isPending ? 'Confirm in Wallet...' : isConfirming ? 'Confirming...' : 'Increment'}
</button>
)
}
Never call useSendCalls without first confirming supportsBatching is true. Calling it against an EOA will throw.
Assemble the page
Compose the components into a single page.import { ConnectWallet } from '@/components/ConnectWallet'
import { CounterDisplay } from '@/components/CounterDisplay'
import { BatchIncrement } from '@/components/BatchIncrement'
export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center gap-8 p-8">
<h1 className="text-3xl font-bold">Onchain Tally</h1>
<ConnectWallet />
<CounterDisplay />
<BatchIncrement />
</main>
)
}
Start the development server.
Speed up confirmations with Flashblocks
Base builds Flashblocks — 200ms incremental blocks that let your app reflect transaction confirmations roughly 10x faster than the standard 2-second block time. Flashblocks are always live; you opt in by pointing Wagmi at a Flashblocks-aware chain, with no other code changes. useWaitForTransactionReceipt then resolves against pre-confirmed state.
Swap the standard chain for its Preconf variant in config/wagmi.ts:
import { http, createConfig } from 'wagmi'
import { baseSepoliaPreconf } from 'wagmi/chains'
export const config = createConfig({
chains: [baseSepoliaPreconf],
// ...connectors and storage unchanged
transports: {
[baseSepoliaPreconf.id]: http(),
},
})
For production, the public endpoints are rate-limited — connect through a Flashblocks-enabled node provider. For the underlying RPC behavior (the pending block tag and supported methods), see the RPC Overview and the Flashblocks Reference.
Next steps
- Go to mainnet — add
base to your chains array and transports in config/wagmi.ts, redeploy your contract to Base mainnet, and update COUNTER_ADDRESS.
- Sponsor gas — use the
paymasterService capability with useSendCalls to cover your users’ transaction fees. See Sponsor Gas.
- Send notifications — use the Notifications guide to fetch opted-in wallet addresses and send in-app notifications.
- Batch read calls — reduce RPC round trips by batching reads via viem’s
multicall.
- Optimistic updates — update the UI before confirmation using TanStack Query’s
onMutate callback.
- Wagmi setup reference — review the full Wagmi setup guide for additional configuration options.