TechLead
Lesson 14 of 20
5 min read
Web3 & Blockchain

Building a dApp with Next.js

Build a complete decentralized application using Next.js, wagmi, viem, and ConnectKit with server components and API routes

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.

Continue Learning