Skip to main content
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

1

Set up your project

Create a new Next.js app and install the required dependencies.
Terminal
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
2

Configure Wagmi for Base

Create the Wagmi config with Base Sepolia, then wrap your app in the required providers.
config/wagmi.ts
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.
app/providers.tsx
'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>.
3

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.
4

Deploy a contract with Foundry

Install Foundry and initialize a contracts directory inside your project.
Terminal
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.
contracts/.env
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.
Terminal
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:
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --private-key $DEPLOYER_PRIVATE_KEY
Deploy the contract.
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --account deployer
Verify the deployment by reading the initial counter value.
Terminal
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.
5

Read contract data

Define your contract address and ABI, then read the current counter value.
config/counter.ts
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.
6

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.
7

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.
8

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.
9

Assemble the page

Compose the components into a single page.
app/page.tsx
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.
Terminal
npm run dev

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.
  • 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.