> ## Documentation Index
> Fetch the complete documentation index at: https://docs.base.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Build an app on Base

> A step-by-step guide to building a Next.js tally app on Base using wagmi and viem, with wallet connection, contract reads and writes, and batch transaction support.

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

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

## Steps

<Steps>
  <Step title="Set up your project">
    Create a new Next.js app and install the required dependencies.

    ```bash Terminal theme={null}
    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
    ```
  </Step>

  <Step title="Configure Wagmi for Base">
    Create the Wagmi config with Base Sepolia, then wrap your app in the required providers.

    ```typescript config/wagmi.ts lines expandable theme={null}
    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
      }
    }
    ```

    <Note>
      `ssr: true` combined with `cookieStorage` prevents Next.js hydration mismatches. The `baseAccount` connector connects users via the [Base Account SDK](/base-account/overview/what-is-base-account) smart wallet — you will detect its capabilities in step 7. The `injected` connector handles browser extension wallets like MetaMask.
    </Note>

    ```typescript app/providers.tsx lines expandable theme={null}
    '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>`.
  </Step>

  <Step title="Connect wallets">
    Create a component that handles all four wallet connection states.

    ```typescript components/ConnectWallet.tsx lines expandable theme={null}
    '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>
      )
    }
    ```

    <Note>
      `useAccount` exposes four states: `isConnecting`, `isReconnecting`, `isConnected`, and `isDisconnected`. Checking only `isConnected` causes UI flashes on page load — handle all four.
    </Note>
  </Step>

  <Step title="Deploy a contract with Foundry">
    Install Foundry and initialize a contracts directory inside your project.

    ```bash Terminal theme={null}
    mkdir contracts && cd contracts
    curl -L https://foundry.paradigm.xyz | bash
    foundryup
    forge init --no-git
    ```

    <Note>
      The `--no-git` flag prevents Foundry from initialising a nested git repository inside your project.
    </Note>

    Configure Base Sepolia in your environment file.

    ```bash contracts/.env theme={null}
    BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
    ```

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

    Load the variable and import your deployer key securely.

    ```bash Terminal theme={null}
    source .env
    cast wallet import deployer --interactive
    ```

    <Warning>
      Never share or commit your private key. `cast wallet import` stores it in `~/.foundry/keystores`, which is not tracked by git.
    </Warning>

    <Note>
      `cast wallet import --interactive` requires a TTY (interactive terminal). In scripted or CI environments, pass the key directly instead:

      ```bash Terminal theme={null}
      forge create ./src/Counter.sol:Counter \
        --rpc-url $BASE_SEPOLIA_RPC_URL \
        --private-key $DEPLOYER_PRIVATE_KEY
      ```
    </Note>

    Deploy the contract.

    ```bash Terminal theme={null}
    forge create ./src/Counter.sol:Counter \
      --rpc-url $BASE_SEPOLIA_RPC_URL \
      --account deployer
    ```

    Verify the deployment by reading the initial counter value.

    ```bash Terminal theme={null}
    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](/base-chain/network-information/network-faucets).
  </Step>

  <Step title="Read contract data">
    Define your contract address and ABI, then read the current counter value.

    ```typescript config/counter.ts lines expandable theme={null}
    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
    ```

    <Note>
      `as const` is required. Without it, wagmi cannot infer function names, argument types, or return types from the ABI.
    </Note>

    ```typescript components/CounterDisplay.tsx lines expandable theme={null}
    '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>
    }
    ```

    <Note>
      `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.
    </Note>
  </Step>

  <Step title="Write to a contract">
    Send a transaction and surface all three confirmation states to the user.

    ```typescript components/IncrementButton.tsx lines expandable theme={null}
    '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>
      )
    }
    ```

    <Note>
      `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.
    </Note>

    Surface three states to the user: waiting for wallet signature, waiting for on-chain confirmation, and success.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="Detect wallet capabilities">
    Smart wallets support batch transactions via EIP-5792. EOAs do not. Detect support before attempting to batch.

    ```typescript hooks/useWalletCapabilities.ts lines expandable theme={null}
    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 }
    }
    ```

    <Note>
      `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.
    </Note>

    See [Batch Transactions with Wagmi](/base-account/framework-integrations/wagmi/batch-transactions) for a deeper look at EIP-5792 capability detection.
  </Step>

  <Step title="Batch transactions with fallback">
    Use `useSendCalls` for smart wallets and `useWriteContract` for EOAs. The component detects which path to take at render time.

    ```typescript components/BatchIncrement.tsx lines expandable theme={null}
    '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>
      )
    }
    ```

    <Warning>
      Never call `useSendCalls` without first confirming `supportsBatching` is `true`. Calling it against an EOA will throw.
    </Warning>
  </Step>

  <Step title="Assemble the page">
    Compose the components into a single page.

    ```typescript app/page.tsx lines expandable theme={null}
    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.

    ```bash Terminal theme={null}
    npm run dev
    ```
  </Step>
</Steps>

## 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](/base-account/improve-ux/sponsor-gas/paymasters).
* **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](/base-account/framework-integrations/wagmi/setup) for additional configuration options.
