← Back to articles
July 202614 min read

How to Build High-Performance Web3 Dashboards with Next.js (2025 Guide)

A practical guide for startup founders and frontend developers on building fast, scalable Web3 dashboards using Next.js, TypeScript, Tailwind CSS, and React Query. Covers architecture, data fetching, performance, and real-world mistakes to avoid.

Next.jsWeb3React QueryDeFi

Why Web3 dashboards are different

Web3 products live or die by their dashboards. A user connecting their wallet for the first time expects to see balances, token values, and recent activity load quickly and reliably. Deliver that, and you have engagement. Fumble it with slow RPC calls, layout shifts, or stale data, and you lose them before they explore your product.

Unlike traditional SaaS dashboards that query a single owned database, a Web3 dashboard aggregates data from RPC nodes, indexing services, price APIs, and sometimes off-chain databases — all at once. What separates a good one from a bad one is how it handles that complexity and still feels instant.

What counts as a Web3 dashboard

Common types include DeFi portfolio trackers, protocol analytics panels (TVL, volume, liquidity), NFT collection dashboards, DAO governance boards, and cross-chain asset managers. Each reads distributed, eventually-consistent blockchain state and must present it in a way users can trust and act on.

Wallet connection: start here

Wallet connection is the entry point and the primary authentication mechanism in Web3. In 2025, the standard stack is wagmi v2 with ConnectKit or RainbowKit for the UI layer. These libraries handle wallet detection, chain switching, and reconnection logic out of the box.

Wrap your app in WagmiProvider and QueryClientProvider at the layout level. Hooks like useAccount, useChainId, and useBalance are React Query–backed under the hood — you get caching, background refetching, and loading states without building that plumbing yourself.

// providers.tsx
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectKitProvider } from 'connectkit';
import { config } from '@/lib/wagmi-config';

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <ConnectKitProvider>{children}</ConnectKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Balances and token data

Balances are what users look at first. Fetching one RPC call per token is slow and hits rate limits fast. Batch ERC-20 balanceOf calls with multicall (via viem or wagmi's useReadContracts) into a single round trip. For fiat values, layer in CoinGecko or DeFiLlama price APIs. Combine multicall results with cached prices so a 50-token portfolio becomes one RPC call instead of fifty.

// hooks/useTokenBalances.ts
import { useReadContracts } from 'wagmi';
import { erc20Abi } from 'viem';

export function useTokenBalances(address: `0x${string}`, tokens: Token[]) {
  return useReadContracts({
    contracts: tokens.map((token) => ({
      address: token.address,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [address],
    })),
    query: {
      staleTime: 30_000,
      refetchInterval: 60_000,
    },
  });
}

Charts and analytics

Charts are the backbone of analytics-heavy dashboards. Recharts works well for TVL over time, allocation pie charts, and bar comparisons. TradingView Lightweight Charts is the right choice for financial-grade candlestick views. Tremor is worth a look if you want KPI cards and charts packaged together. Pick one chart library per concern and keep chart containers at fixed heights to avoid layout shift when data loads.

// components/charts/TVLChart.tsx
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

interface Props {
  data: { date: string; tvl: number }[];
}

export function TVLChart({ data }: Props) {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <AreaChart data={data}>
        <defs>
          <linearGradient id="tvlGradient" x1="0" y1="0" x2="0" y2="1">
            <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
            <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
          </linearGradient>
        </defs>
        <XAxis dataKey="date" tick={{ fontSize: 12 }} />
        <YAxis tickFormatter={(v) => `$${(v / 1e6).toFixed(1)}M`} />
        <Tooltip formatter={(v: number) => [`$${v.toLocaleString()}`, 'TVL']} />
        <Area type="monotone" dataKey="tvl" stroke="#6366f1" fill="url(#tvlGradient)" />
      </AreaChart>
    </ResponsiveContainer>
  );
}

Activity logs and transaction history

Transaction feeds need pagination and near-real-time updates. The Graph is excellent for indexed on-chain events via GraphQL. Etherscan or Alchemy Transfers APIs work well for standard ERC-20 and native transfers. Ponder is a newer self-hostable option. Use cursor-based pagination, not offset pagination — volatile on-chain data causes rows to shift between page loads with offset-based approaches.

// hooks/useTransactionHistory.ts
import { useInfiniteQuery } from '@tanstack/react-query';

export function useTransactionHistory(address: string) {
  return useInfiniteQuery({
    queryKey: ['txHistory', address],
    queryFn: ({ pageParam }) => fetchTransactions(address, pageParam),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 20_000,
  });
}

Recommended tech stack

Use Next.js 14+ App Router for RSC, route-level caching, and streaming. TypeScript catches chain-data shape errors at compile time. Tailwind CSS keeps styling overhead low. TanStack Query v5 handles server state. wagmi v2 + viem covers blockchain hooks. ConnectKit or RainbowKit for wallet UI. Recharts plus TradingView Lightweight for charts. The Graph or Alchemy for indexing. Shadcn/ui or Radix for accessible base components.

The App Router lets you fetch non-wallet-gated data (protocol stats, price feeds, global metrics) on the server, cutting client JavaScript and improving first paint. Reserve client components for wallet context and interactivity.

Component architecture that scales

Keep three concerns separated. The data layer — all fetching in hooks or server-side fetch calls; components never call RPC nodes directly. The presentation layer — components receive typed props and render; they don't know where data came from. Wallet context — isolated to a provider wrapper; only components that need wallet state import from wagmi.

A practical folder layout: app/dashboard/page.tsx as an RSC for public protocol data, components/dashboard/ for WalletSummary, TVLChart, TokenTable, hooks/ for useTokenBalances and useTransactionHistory, lib/ for wagmi-config and fetchers.

// app/dashboard/page.tsx (Server Component)
import { TokenTable } from '@/components/dashboard/TokenTable';
import { TVLChart } from '@/components/dashboard/TVLChart';
import { fetchProtocolMetrics } from '@/lib/fetchers';

export default async function DashboardPage() {
  const metrics = await fetchProtocolMetrics();

  return (
    <main>
      <TVLChart data={metrics.tvlHistory} />
      <TokenTable tokens={metrics.topTokens} />
    </main>
  );
}

Performance: batch, cache, virtualize

Never loop individual eth_call requests — use multicall3 to batch thousands of reads into one RPC round trip. Configure staleTime and refetchInterval deliberately; on-chain data from 30 seconds ago is usually good enough for display. Virtualize long transaction tables with TanStack Virtual so the DOM stays lean at any list size. Prefetch dashboard data on link hover with queryClient.prefetchQuery. Cache public API routes at the edge with Cache-Control s-maxage and stale-while-revalidate headers.

import { publicClient } from '@/lib/viem-client';
import { erc20Abi } from 'viem';

const results = await publicClient.multicall({
  contracts: tokenAddresses.map((address) => ({
    address,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: [userAddress],
  })),
});
const { data } = useQuery({
  queryKey: ['tvl'],
  queryFn: fetchTVL,
  staleTime: 30_000,
  refetchInterval: 60_000,
  gcTime: 5 * 60_000,
});
import { useVirtualizer } from '@tanstack/react-virtual';

const rowVirtualizer = useVirtualizer({
  count: transactions.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 56,
});
// app/api/protocol-metrics/route.ts
export async function GET() {
  const data = await fetchMetrics();
  return Response.json(data, {
    headers: { 'Cache-Control': 's-maxage=60, stale-while-revalidate=300' },
  });
}

Mistakes that kill user experience

Calling RPC nodes from the browser without a backend proxy exposes your API key in DevTools — route calls through a Next.js API route or use domain-restricted keys. Fetching on every render without staleTime fires a new request on every component mount. Ignoring RPC rate limits on public endpoints breaks under real traffic. Not handling wallet disconnection gracefully leaves panels in broken states. Skipping Suspense boundaries means one slow fetch blocks the entire page — wrap each independent data region with a skeleton fallback.

<Suspense fallback={<BalanceSkeleton />}>
  <WalletBalances address={address} />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
  <TVLChart />
</Suspense>

Over-fetching full transaction history on every visit is expensive; paginate and cache. Mixing chain IDs without validating the contract exists on the connected chain returns wrong data silently. Treating isLoading and isFetching the same replaces entire panels with spinners on background refresh — use subtle indicators instead.

Pre-launch checklist

Before shipping: keep RPC keys server-side only, batch all multi-contract reads with multicall, configure staleTime per data type, wrap every data region in Suspense with matching skeletons, virtualize large lists, handle wallet disconnect cleanly, edge-cache public API routes, validate chain ID before contract calls, and test mobile layouts with horizontal scroll on tables and charts.

FAQ: indexer, wagmi vs ethers, and SSR

For indexing, start with The Graph for event-based historical data. Move to Ponder, Envio, or Goldsky only when you need non-standard aggregations or sub-second latency. For new React dashboard projects, prefer wagmi v2 over raw ethers.js — it is built on viem, integrates React Query natively, and handles multi-wallet support without custom adapter code.

Keep token prices fresh by batching CoinGecko simple/price calls (up to 250 IDs per request) and caching at the edge for 30–60 seconds. For multi-chain dashboards, pass chainId into every data hook and use a chain config map for RPC URLs, explorers, and contract addresses.

SSR helps for public protocol metrics, token lists, and price charts. Wallet-specific views belong in client components. The App Router hybrid — RSC for public data, client components for wallet interaction — is the architecture that scales from MVP to production without a rewrite.

Start small, then scale

Building a Web3 dashboard users trust is mostly a data architecture problem. Get caching, batching, and component boundaries right from the start, and the user experience takes care of itself. Start with wallet balance and one useful chart, validate with real users, and add complexity only where the data demands it.

Want to work together? I build Web3 dashboards and DeFi interfaces.