← Back to articles
May 202611 min read

Frontend Architecture for Data-Heavy Dashboards: A Practical Guide

Component structure, state management, API layers, caching, and folder organization for Web3, finance, and analytics dashboards that scale beyond MVP.

ArchitectureDashboardsReactTypeScript

Why architecture matters for data-heavy dashboards

A dashboard that works with 5 data points breaks with 5,000. The difference is not the UI library — it is how you structure components, manage state, cache data, and handle loading at scale. This guide covers the architecture patterns that keep Web3, finance, and analytics dashboards maintainable as they grow.

Component structure

Separate data fetching from presentation. Components receive typed props and render UI. They never call APIs or RPC nodes directly.

components/
  dashboard/
    PortfolioSummary.tsx    # Presentation only
    TokenTable.tsx
    TVLChart.tsx
  ui/
    Skeleton.tsx
    DataTable.tsx
    ErrorBoundary.tsx
hooks/
  usePortfolio.ts             # Data fetching
  useTokenPrices.ts
  useTransactions.ts
lib/
  api-client.ts               # HTTP layer
  format.ts                   # Utilities
  types.ts                    # Shared types

Container/presenter pattern works well: a thin container hook fetches data, a presenter component renders it.

// hooks/usePortfolio.ts
export function usePortfolio(address: string) {
  const balances = useTokenBalances(address);
  const prices = useTokenPrices(balances.data?.tokens);
  const totalUsd = useMemo(() => computeTotal(balances.data, prices.data), [balances.data, prices.data]);

  return {
    positions: balances.data,
    totalUsd,
    isLoading: balances.isLoading || prices.isLoading,
    isError: balances.isError || prices.isError,
  };
}

// components/dashboard/PortfolioSummary.tsx
export function PortfolioSummary({ address }: { address: string }) {
  const { totalUsd, isLoading, isError } = usePortfolio(address);
  return <DataState isLoading={isLoading} isError={isError}><ValueDisplay value={totalUsd} /></DataState>;
}

State management layers

Three layers, three tools. Wallet state: wagmi. Server/cache state: TanStack Query. UI state: Zustand or local useState. Never store API responses in Zustand — you lose caching, deduplication, and background refetch.

API and data layer

Centralize all HTTP and RPC calls in a service layer. Components and hooks call service functions, never fetch directly.

// lib/api-client.ts
const BASE_URL = '/api';

async function apiGet<T>(path: string, params?: Record<string, string>): Promise<T> {
  const url = new URL(path, BASE_URL);
  if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
  const res = await fetch(url.toString());
  if (!res.ok) throw new ApiError(res.status, await res.text());
  return res.json();
}

export const api = {
  getPortfolio: (address: string) => apiGet<Portfolio>(`/portfolio/${address}`),
  getTransactions: (address: string, cursor?: string) =>
    apiGet<TransactionPage>('/transactions', { address, cursor: cursor ?? '' }),
  getTokenPrices: (ids: string[]) => apiGet<PriceMap>('/prices', { ids: ids.join(',') }),
};

Caching strategy

Configure staleTime per data type based on how frequently it changes:

export const CACHE_CONFIG = {
  tokenMetadata: { staleTime: 3_600_000, gcTime: 86_400_000 },
  tokenPrices: { staleTime: 30_000, refetchInterval: 60_000 },
  balances: { staleTime: 30_000, refetchInterval: 60_000 },
  transactions: { staleTime: 20_000, refetchInterval: 30_000 },
  protocolStats: { staleTime: 120_000, refetchInterval: 300_000 },
} as const;

Invalidate related queries after mutations:

const queryClient = useQueryClient();

const { writeContract } = useWriteContract({
  mutation: {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['balances'] });
      queryClient.invalidateQueries({ queryKey: ['transactions'] });
    },
  },
});

Table and chart performance

Virtualize tables over 100 rows with TanStack Virtual. Lazy-load chart libraries with next/dynamic. Memoize chart data transformations. Use fixed-height chart containers.

import dynamic from 'next/dynamic';

const PriceChart = dynamic(() => import('@/components/charts/PriceChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

Loading and error states

Wrap every independent data region in its own loading boundary. One slow API should not block the entire dashboard.

export function DashboardPage() {
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
      <Suspense fallback={<SummarySkeleton />}>
        <PortfolioSummary />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <TVLChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <TransactionTable />
      </Suspense>
    </div>
  );
}

Folder structure for scaling

src/
  app/                    # Next.js routes
  features/               # Feature modules
    portfolio/
      components/
      hooks/
      types.ts
    transactions/
      components/
      hooks/
      types.ts
  components/ui/          # Shared primitives
  lib/                    # Config, clients, utils
  hooks/                  # Shared hooks
  types/                  # Global types

FAQ

**When should I split into feature folders?** When a feature has 3+ components and 2+ hooks. Before that, flat structure is fine.

**How do I share data between features?** Shared React Query cache via matching queryKeys. Not global state.

**Should charts be client or server components?** Client. Charts need browser APIs and interactivity. Load them with dynamic import.

**How do I handle 10,000-row tables?** TanStack Virtual for DOM virtualization. Server-side pagination for data fetching. Never load 10,000 rows into memory at once.

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