Build a Full-Stack dApp in 2026 (Next.js, wagmi, viem)
How to build a dApp in 2026: connect wallets, read and write a Solidity contract, show events and deploy, using Next.js, wagmi v3 and viem. Testnet code.
Build a Full-Stack dApp in 2026 (Next.js, wagmi, viem)To build a dApp in 2026, deploy a tested smart contract to a testnet, then create a Next.js app that uses wagmi for wallet connection and React hooks, viem for low-level Ethereum calls, and TanStack Query for caching. The frontend reads contract state, sends transactions from the user's wallet, waits for confirmation and shows on-chain events. This guide builds a complete tipping dApp on Sepolia using wagmi v3, which renamed some hooks you will see in older tutorials.
Key takeaways
- A dApp has three layers: the contract (on-chain), the frontend (in the browser), and data access (RPC calls and, at scale, an indexer).
- wagmi v3 renamed
useAccounttouseConnectionand mutation hooks now returnmutateandmutateAsync. - Connector SDKs (MetaMask, WalletConnect, Coinbase) are optional peer dependencies in wagmi v3; the
injectedconnector needs none. - Always handle loading, error, wrong-network and "waiting for confirmation" states.
- Keep everything on testnet until the contract is reviewed.
dApp architecture in one picture
Browser (Next.js + wagmi)
│ read: eth_call via RPC write: user signs in wallet
▼ ▼
RPC provider ───────────────► Ethereum / L2 network ◄── TipJar contract
▲
│ events (getLogs) or an indexer for history and search
- Reads (
viewfunctions) are free calls through an RPC provider. - Writes are transactions the user signs in their wallet and pays gas for.
- Events are logs the contract emits; you query them for history.
We use the TipJar contract from BC-02. If you have not deployed it yet, follow that tutorial first and note the contract address and the block number it was deployed in.
Step 1: Create the Next.js project
npx create-next-app@latest tipjar-dapp --typescript --app
cd tipjar-dapp
npm install wagmi viem @tanstack/react-query
wagmi v3 requires TypeScript 5.9.3 or newer, which current create-next-app templates already use.
Add your RPC URL to .env.local (this is a public endpoint URL, not a secret key, but keep paid keys restricted by domain in your provider's dashboard):
NEXT_PUBLIC_SEPOLIA_RPC_URL=https://your-sepolia-rpc-url
NEXT_PUBLIC_TIPJAR_ADDRESS=0xYourDeployedTipJarAddress
NEXT_PUBLIC_TIPJAR_DEPLOY_BLOCK=0
Set NEXT_PUBLIC_TIPJAR_DEPLOY_BLOCK to the block number shown for your deployment on Sepolia Etherscan.
Step 2: Configure wagmi
Create lib/wagmi.ts:
import { http, createConfig } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { injected } from 'wagmi/connectors'
export const config = createConfig({
chains: [sepolia],
connectors: [injected()],
transports: {
[sepolia.id]: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL),
},
ssr: true,
})
declare module 'wagmi' {
interface Register {
config: typeof config
}
}
injected()works with browser-extension wallets such as MetaMask without extra packages.ssr: trueavoids hydration mismatches in Next.js.- The
Registerblock gives you full type inference across hooks.
If you later want WalletConnect for mobile wallets, install @walletconnect/ethereum-provider and add the walletConnect({ projectId }) connector. In wagmi v3 these SDKs are no longer bundled.
Step 3: Add the providers
Create app/providers.tsx:
'use client'
import { useState, type ReactNode } from 'react'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { config } from '@/lib/wagmi'
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
)
}
Wrap the app in app/layout.tsx:
import type { ReactNode } from 'react'
import { Providers } from './providers'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Step 4: Add the contract ABI
The ABI tells viem how to encode calls. You can copy it from out/TipJar.sol/TipJar.json in your Foundry project. For type safety, declare it as const.
Create lib/tipjar.ts:
export const TIPJAR_ADDRESS = process.env.NEXT_PUBLIC_TIPJAR_ADDRESS as `0x${string}`
export const tipJarAbi = [
{
type: 'function',
name: 'tip',
stateMutability: 'payable',
inputs: [{ name: 'message', type: 'string' }],
outputs: [],
},
{
type: 'function',
name: 'totalTips',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
},
{
type: 'function',
name: 'owner',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'address' }],
},
{
type: 'event',
name: 'Tipped',
inputs: [
{ name: 'from', type: 'address', indexed: true },
{ name: 'amount', type: 'uint256', indexed: false },
{ name: 'message', type: 'string', indexed: false },
],
},
] as const
Step 5: Connect a wallet
Create components/ConnectButton.tsx:
'use client'
import { useConnection, useConnect, useConnectors, useDisconnect } from 'wagmi'
export function ConnectButton() {
const { address, isConnected, chain } = useConnection()
const connectors = useConnectors()
const { mutate: connect, isPending } = useConnect()
const { mutate: disconnect } = useDisconnect()
if (isConnected) {
return (
<div>
<p>Connected: {address} {chain ? `on ${chain.name}` : '(unsupported network)'}</p>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
)
}
return (
<div>
{connectors.map((connector) => (
<button
key={connector.uid}
disabled={isPending}
onClick={() => connect({ connector })}
>
Connect {connector.name}
</button>
))}
</div>
)
}
chain is undefined when the wallet is on a network that is not in your config. That is your cue to show a "Switch to Sepolia" button using useSwitchChain.
Step 6: Read and write the contract
Create components/TipForm.tsx:
'use client'
import { useEffect, useState, type FormEvent } from 'react'
import { formatEther, parseEther } from 'viem'
import {
useConnection,
useReadContract,
useWaitForTransactionReceipt,
useWriteContract,
} from 'wagmi'
import { TIPJAR_ADDRESS, tipJarAbi } from '@/lib/tipjar'
export function TipForm() {
const { isConnected } = useConnection()
const [amount, setAmount] = useState('0.001')
const [message, setMessage] = useState('')
const { data: total, refetch } = useReadContract({
address: TIPJAR_ADDRESS,
abi: tipJarAbi,
functionName: 'totalTips',
})
const { data: hash, mutate: writeContract, isPending, error } = useWriteContract()
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash })
useEffect(() => {
if (isSuccess) refetch()
}, [isSuccess, refetch])
function submit(e: FormEvent) {
e.preventDefault()
writeContract({
address: TIPJAR_ADDRESS,
abi: tipJarAbi,
functionName: 'tip',
args: [message],
value: parseEther(amount),
})
}
return (
<form onSubmit={submit}>
<p>Total tipped: {total !== undefined ? formatEther(total) : '...'} ETH (testnet)</p>
<input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.001" />
<input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Message" />
<button type="submit" disabled={!isConnected || isPending || isConfirming}>
{isPending ? 'Confirm in wallet...' : isConfirming ? 'Waiting for block...' : 'Send tip'}
</button>
{hash && <p>Transaction: {hash}</p>}
{isSuccess && <p>Confirmed.</p>}
{error && <p>Error: {error.message}</p>}
</form>
)
}
What is happening:
useReadContractperforms aneth_calland caches the result with TanStack Query.useWriteContractasks the wallet to sign and send. In wagmi v3 the function ismutate, renamed here towriteContractfor readability.useWaitForTransactionReceiptwaits until the transaction is mined, then we refetch the total.parseEtherandformatEtherfrom viem convert between ETH strings andbigintwei.
Step 7: Show recent tips from events
For a small app, read events directly with viem. Create lib/getTips.ts:
import { createPublicClient, http, parseAbiItem } from 'viem'
import { sepolia } from 'viem/chains'
import { TIPJAR_ADDRESS } from './tipjar'
const client = createPublicClient({
chain: sepolia,
transport: http(process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL),
})
export async function getRecentTips() {
const logs = await client.getLogs({
address: TIPJAR_ADDRESS,
event: parseAbiItem('event Tipped(address indexed from, uint256 amount, string message)'),
fromBlock: BigInt(process.env.NEXT_PUBLIC_TIPJAR_DEPLOY_BLOCK ?? '0'),
toBlock: 'latest',
})
return logs.map((log) => ({
from: log.args.from,
amount: log.args.amount,
message: log.args.message,
txHash: log.transactionHash,
}))
}
You can call this from a server component or a route handler and render the list. For live updates in the browser, wagmi's useWatchContractEvent hook subscribes to new events.
When you need an indexer
Many RPC providers limit how many blocks one getLogs call can scan. Once your contract has a long history, or you need search, filtering and pagination, use an indexer such as The Graph or Ponder. It listens to events, stores them in a database and gives your frontend a fast API. That is also a common freelance task (see BC-08).
Step 8: Put it on the page
Edit app/page.tsx:
import { ConnectButton } from '@/components/ConnectButton'
import { TipForm } from '@/components/TipForm'
export default function Home() {
return (
<main>
<h1>TipJar (Sepolia testnet)</h1>
<ConnectButton />
<TipForm />
</main>
)
}
Run it:
npm run dev
Open http://localhost:3000, connect a wallet set to Sepolia with test ETH, and send a tip.
Step 9: Deploy the frontend
- Push the project to GitHub.
- Deploy on Vercel, Netlify or any host that supports Next.js, and add the same
NEXT_PUBLIC_variables in the host's settings. - Restrict your RPC key to your domain in the provider's dashboard so others cannot reuse it.
- Link the deployed site in your contract's README and in your portfolio.
Production checklist for a dApp
| Area | What to check |
|---|---|
| Network | Detect wrong chain; offer a switch button |
| UX states | Wallet prompt, pending, confirming, success, error |
| Errors | Show human messages for user rejection and reverts (custom errors decode with the ABI) |
| Amounts | Validate input before parseEther; never use floating point for money |
| Security | No private keys in frontend code; contract reviewed (BC-03) |
| Data | Indexer for history once logs grow |
| Mobile | WalletConnect or a mobile-friendly connector |
| Disclosures | Clear "testnet" labels and terms if real users are involved |
Turning the tutorial into a product
This dApp is a template that many people need in some form: donation pages for creators, payment links, event check-ins, membership passes. A clean, documented starter kit with tests and a deploy guide is a digital product. A custom version for a client is a service. Either way, ship it on testnet first and be honest about what it does and does not do.
Next step with Digital Silk Route
A full-stack dApp is a natural capstone in the Blockchain track of the Digital Silk Route (DSR) AI & Crypto Academy, which runs from Beginner to Intermediate to Advanced with a shippable project at every level. Once your dApp or starter kit is tested and documented, you can list it on the DSR Marketplace for other developers and businesses. Learn. Build. Earn.
Related reading
- Your First Smart Contract: Solidity and Foundry Tutorial (2026)
- Which Blockchain Should You Build On in 2026? Ethereum, L2s, Solana and More
- Shipping a Global SaaS from Pakistan: The 2026 Stack and Playbook
- How to Sell Digital Products Online in 2026 (Full Guide)
Sources
- wagmi: migrate from v2 to v3
- wagmi: createConfig
- viem documentation: getLogs
- TanStack Query documentation
- Next.js documentation: installation
- Foundry documentation
This article is for education only and is not financial, legal or investment advice.


