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.
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 typesContainer/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 typesFAQ
**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.