diff --git a/docs/apps/quickstart/build-app.mdx b/docs/apps/quickstart/build-app.mdx new file mode 100644 index 000000000..0dd405fc4 --- /dev/null +++ b/docs/apps/quickstart/build-app.mdx @@ -0,0 +1,547 @@ +--- +title: "Build an app on Base" +description: "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 + + +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 + + + + Create a new Next.js app and install the required dependencies. + + ```bash 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 + ``` + + + + Create the Wagmi config with Base Sepolia, then wrap your app in the required providers. + + ```typescript config/wagmi.ts lines expandable + 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](/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. + + + ```typescript app/providers.tsx lines expandable + '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 ( + + + {children} + + + ) + } + ``` + + Wrap your root layout with ``. + + + + Create a component that handles all four wallet connection states. + + ```typescript components/ConnectWallet.tsx lines expandable + '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
Reconnecting...
+ + if (!isConnected) { + return ( +
+ {connectors.map((connector) => ( + + ))} +
+ ) + } + + return ( +
+ + {address?.slice(0, 6)}...{address?.slice(-4)} + + +
+ ) + } + ``` + + + `useAccount` exposes four states: `isConnecting`, `isReconnecting`, `isConnected`, and `isDisconnected`. Checking only `isConnected` causes UI flashes on page load — handle all four. + +
+ + + Install Foundry and initialize a contracts directory inside your project. + + ```bash 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. + + ```bash 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`. This endpoint is also rate-limited and not recommended for production apps. For production apps, use a dedicated RPC provider. + + + Load the variable and import your deployer key securely. + + ```bash 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: + + ```bash Terminal + forge create ./src/Counter.sol:Counter \ + --rpc-url $BASE_SEPOLIA_RPC_URL \ + --private-key $DEPLOYER_PRIVATE_KEY + ``` + + + Deploy the contract. + + ```bash Terminal + 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 + cast call "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). + + + + Define your contract address and ABI, then read the current counter value. + + ```typescript config/counter.ts lines expandable + 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. + + + ```typescript components/CounterDisplay.tsx lines expandable + '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

Loading...

+ if (isError && count === undefined) return

Failed to read contract

+ + return

{count?.toString()}

+ } + ``` + + + `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. + +
+ + + Send a transaction and surface all three confirmation states to the user. + + ```typescript components/IncrementButton.tsx lines expandable + '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 ( + + ) + } + + return ( +
+ + {isSuccess &&

Confirmed!

} + {hash && ( + + View on Basescan + + )} +
+ ) + } + ``` + + + `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. + +
+ + + Smart wallets support batch transactions via EIP-5792. EOAs do not. Detect support before attempting to batch. + + ```typescript hooks/useWalletCapabilities.ts lines expandable + 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](/base-account/framework-integrations/wagmi/batch-transactions) for a deeper look at EIP-5792 capability detection. + + + + 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 + '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

Connect your wallet first.

+ + return supportsBatching ? : + } + + 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 ( + + ) + } + + const incrementData = encodeFunctionData({ + abi: counterAbi, + functionName: 'increment', + }) + + return ( +
+ + {isSuccess &&

Batch confirmed!

} +
+ ) + } + + 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 ( + + ) + } + + return ( + + ) + } + ``` + + + Never call `useSendCalls` without first confirming `supportsBatching` is `true`. Calling it against an EOA will throw. + +
+ + + Compose the components into a single page. + + ```typescript app/page.tsx lines expandable + import { ConnectWallet } from '@/components/ConnectWallet' + import { CounterDisplay } from '@/components/CounterDisplay' + import { BatchIncrement } from '@/components/BatchIncrement' + + export default function Home() { + return ( +
+

Onchain Tally

+ + + +
+ ) + } + ``` + + Start the development server. + + ```bash 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](/base-account/improve-ux/sponsor-gas/paymasters). +- **Send notifications** — use the [Notifications guide](/apps/technical-guides/base-notifications) 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](/base-account/framework-integrations/wagmi/setup) for additional configuration options.