Next.js for Web3
Next.js is the ideal framework for building dApp frontends. Its server-side rendering improves SEO for your project's landing pages, API routes handle backend logic like SIWE authentication, and the App Router provides a clean component architecture. Combined with wagmi for React hooks and viem for low-level blockchain interaction, you get a type-safe, performant dApp.
Project Setup
// Create a new Next.js dApp project
// npx create-next-app@latest my-dapp --typescript --tailwind --app
// cd my-dapp
// npm install wagmi viem @tanstack/react-query connectkit
// app/providers.tsx — Client-side providers
'use client';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectKitProvider, getDefaultConfig } from 'connectkit';
const config = createConfig(
getDefaultConfig({
chains: [mainnet, sepolia],
transports: {
[mainnet.id]: http(`https://eth-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_ID}`),
[sepolia.id]: http(`https://eth-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_ID}`),
},
walletConnectProjectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID!,
appName: 'My dApp',
})
);
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider>
{children}
</ConnectKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
// app/layout.tsx — Root layout wrapping children with providers
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>
<nav className="flex justify-between items-center p-4 border-b">
<h1 className="text-xl font-bold">My dApp</h1>
<ConnectButton />
</nav>
{children}
</Providers>
</body>
</html>
);
}
// components/ConnectButton.tsx
'use client';
import { ConnectKitButton } from 'connectkit';
export function ConnectButton() {
return <ConnectKitButton />;
}
Contract Interaction Component
// lib/contracts.ts — Shared contract configurations
export const vaultContract = {
address: '0x1234...' as const,
abi: [
{
name: 'deposit',
type: 'function',
stateMutability: 'payable',
inputs: [],
outputs: [],
},
{
name: 'withdraw',
type: 'function',
stateMutability: 'nonpayable',
inputs: [{ name: 'amount', type: 'uint256' }],
outputs: [],
},
{
name: 'balances',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'user', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
},
{
name: 'Deposited',
type: 'event',
inputs: [
{ name: 'user', type: 'address', indexed: true },
{ name: 'amount', type: 'uint256', indexed: false },
],
},
] as const,
} as const;
// components/VaultDashboard.tsx
'use client';
import { useState } from 'react';
import {
useAccount,
useReadContract,
useWriteContract,
useWaitForTransactionReceipt,
useWatchContractEvent,
} from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { vaultContract } from '@/lib/contracts';
export function VaultDashboard() {
const { address, isConnected } = useAccount();
const [depositAmount, setDepositAmount] = useState('');
// Read user balance from contract
const { data: balance, refetch: refetchBalance } = useReadContract({
...vaultContract,
functionName: 'balances',
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Write: deposit
const {
writeContract: deposit,
data: depositHash,
isPending: isDepositing,
} = useWriteContract();
const { isLoading: isConfirmingDeposit, isSuccess: depositConfirmed } =
useWaitForTransactionReceipt({
hash: depositHash,
onSuccess: () => refetchBalance(),
});
// Watch for Deposited events (real-time)
useWatchContractEvent({
...vaultContract,
eventName: 'Deposited',
onLogs(logs) {
logs.forEach((log) => {
console.log('New deposit:', log.args);
});
refetchBalance();
},
});
if (!isConnected) {
return <p>Connect your wallet to use the Vault.</p>;
}
return (
<div className="max-w-md mx-auto p-6 border rounded-xl">
<h2 className="text-2xl font-bold mb-4">Vault</h2>
<p className="mb-4">
Your balance: {balance ? formatEther(balance) : '0'} ETH
</p>
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="ETH amount"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
className="border rounded px-3 py-2 flex-1"
/>
<button
onClick={() =>
deposit({
...vaultContract,
functionName: 'deposit',
value: parseEther(depositAmount),
})
}
disabled={isDepositing || isConfirmingDeposit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isDepositing ? 'Confirm in wallet...' : isConfirmingDeposit ? 'Confirming...' : 'Deposit'}
</button>
</div>
{depositConfirmed && (
<p className="text-green-600">Deposit confirmed!</p>
)}
</div>
);
}
Server-Side Blockchain Data
// app/api/token-info/route.ts — API route for server-side blockchain reads
import { createPublicClient, http, formatUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { NextResponse } from 'next/server';
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.ALCHEMY_RPC_URL),
});
const erc20Abi = [
{ name: 'name', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
{ name: 'totalSupply', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
{ name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
] as const;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const tokenAddress = searchParams.get('address') as `0x${string}`;
const [name, totalSupply, decimals] = await Promise.all([
client.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'name' }),
client.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'totalSupply' }),
client.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'decimals' }),
]);
return NextResponse.json({
name,
totalSupply: formatUnits(totalSupply, decimals),
decimals,
});
}
Next.js dApp Tips
- Use 'use client' correctly: Only wallet-connected components need to be client components. Keep pages as server components when possible.
- Environment variables: Use NEXT_PUBLIC_ prefix for client-side keys (RPC URLs). Keep private keys server-side only.
- Loading states: Every blockchain interaction has latency. Show pending/confirming/confirmed states clearly.
- Error handling: Users can reject transactions, gas can be insufficient, RPC can be down. Handle all these gracefully.
- TypeScript + ABI: Use const assertions on your ABI arrays for full type inference with wagmi hooks.