Admin dashboards are one of the most common things clients ask for — and one of the most underestimated in terms of complexity. A basic table is easy. A production dashboard with sortable/filterable tables, real-time WebSocket updates, role-based permissions, responsive mobile layout, and bilingual Arabic/English support is a different beast. This guide covers everything you need to build one that actually works in production.
Dashboard Design Principles
Before writing a single line of code, think about information hierarchy. What does the user need to see first? For most admin dashboards, the answer is KPI summary cards at the top (total users, revenue, orders today), followed by a trend chart, then a data table of recent activity. Keep navigation in a persistent sidebar on desktop and a collapsible drawer on mobile. Every page should be reachable in at most two clicks from the sidebar.
Resist the temptation to put everything on the home screen. Data density kills usability. The best dashboards show about seven numbers at a glance — the brain can process no more than that before it starts ignoring data.
Project Setup — Next.js
Use Next.js with the App Router for a dashboard. Server Components handle data-fetching without useEffect boilerplate, and the file-system routing maps naturally to dashboard sections:
npx create-next-app@latest my-dashboard \
--typescript --tailwind --eslint --app --src-dir
# Install dashboard-specific packages
npm install recharts @tanstack/react-table \
@tanstack/react-query zustand \
react-hook-form zod @hookform/resolvers \
lucide-react class-variance-authority clsx
The project structure groups by feature, with a shared components/ui directory for reusable primitives (Button, Card, Badge, Modal):
src/
├── app/
│ ├── (auth)/login/page.tsx
│ └── (dashboard)/
│ ├── layout.tsx # Sidebar + header wrapper
│ ├── page.tsx # Overview / home
│ ├── users/page.tsx
│ ├── orders/page.tsx
│ └── analytics/page.tsx
├── components/
│ ├── ui/ # Button, Card, Badge, etc.
│ ├── layout/
│ │ ├── Sidebar.tsx
│ │ └── Header.tsx
│ └── features/
│ ├── DataTable.tsx
│ └── StatsCard.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useWebSocket.ts
└── stores/
└── authStore.ts
Sidebar + Header Layout
The dashboard layout is a CSS Grid or Flexbox split: sidebar at a fixed width (240px) on the left, main content filling the remainder. The sidebar collapses to an icon-only mode on smaller screens and to a full drawer on mobile:
// src/app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/layout/Sidebar';
import { Header } from '@/components/layout/Header';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen bg-background">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
Auth and Protected Routes
In Next.js App Router, protect dashboard routes using a middleware file at the project root. It runs on the Edge and redirects unauthenticated users before the page renders:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const token = req.cookies.get('access_token')?.value;
const isAuthPage = req.nextUrl.pathname.startsWith('/login');
if (!token && !isAuthPage) {
return NextResponse.redirect(new URL('/login', req.url));
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL('/', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
};
Data Tables with TanStack Table
TanStack Table v8 (formerly React Table) is the gold standard for headless data tables. It handles sorting, filtering, pagination, and row selection entirely in memory, which is appropriate for datasets up to ~10,000 rows:
// src/components/features/DataTable.tsx
'use client';
import { useReactTable, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getPaginationRowModel,
flexRender, type ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
}
export function DataTable<T>({ data, columns }: DataTableProps<T>) {
const [sorting, setSorting] = useState([]);
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div>
<input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search..." className="mb-4 input" />
<table className="w-full">
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} onClick={h.column.getToggleSortingHandler()}
className="cursor-pointer text-left p-3">
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[h.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="border-t hover:bg-muted/50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="p-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Pagination controls */}
<div className="flex items-center gap-2 mt-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</button>
<span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
</div>
</div>
);
}
Charts with Recharts
Recharts is a React-native charting library built on D3. It composes well with React and is fully typed. For a dashboard you typically need: a LineChart for trends, a BarChart for comparisons, and a PieChart or RadarChart for distribution. Keep chart components in a features/analytics/ folder and pass data as props:
'use client';
import { LineChart, Line, XAxis, YAxis, Tooltip,
CartesianGrid, ResponsiveContainer } from 'recharts';
export function RevenueChart({ data }: { data: { date: string; revenue: number }[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }} />
<Tooltip contentStyle={{ background: 'var(--card)', border: '1px solid var(--border)' }} />
<Line type="monotone" dataKey="revenue" stroke="var(--primary)"
strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
CRUD Operations
Use TanStack Query for all server state. It handles caching, background refetching, optimistic updates, and loading/error states so you do not have to manage them manually. Pair mutations with query invalidation to keep tables in sync:
// hooks/useUsers.ts
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users').then(r => r.data),
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/users/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
Real-Time Updates with WebSocket
For live dashboards — order notifications, user activity feeds — a WebSocket connection pushes data without polling. Create a custom hook that connects once and updates a Zustand store:
// hooks/useWebSocket.ts
'use client';
import { useEffect } from 'react';
import { useNotificationStore } from '@/stores/notificationStore';
export function useWebSocket(url: string) {
const addNotification = useNotificationStore(s => s.add);
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'new_order') {
addNotification({ id: data.id, message: `New order #${data.orderId}`, read: false });
}
};
ws.onerror = (err) => console.error('WebSocket error', err);
return () => ws.close();
}, [url, addNotification]);
}
Dark Mode
Implement dark mode with CSS custom properties and a data attribute on <html>. Tailwind's darkMode: 'class' or 'selector' strategy works well with this approach. Persist the user's preference in localStorage and read it before the first render to avoid a flash:
// Add this to the <head> as an inline script to prevent FOUC
(function() {
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
RTL Arabic Layout
For bilingual dashboards, the sidebar flip and table alignment are the trickiest parts. Use logical CSS properties throughout (margin-inline-start, padding-inline-end) rather than margin-left / padding-right. In Tailwind, use ms- and me- utilities (margin-start / margin-end) which respect the document direction automatically. Toggle direction on the <html> element:
function setLocale(locale: 'en' | 'ar') {
document.documentElement.setAttribute('lang', locale);
document.documentElement.setAttribute('dir', locale === 'ar' ? 'rtl' : 'ltr');
localStorage.setItem('locale', locale);
}
Responsive Mobile Admin
On mobile (below 768px), the sidebar becomes a bottom navigation bar or a hamburger-triggered drawer. Use a Zustand store to manage the sidebar open/close state so any component can toggle it. The main content area gets full width, tables become horizontally scrollable, and cards stack vertically. Test on an actual low-end Android device — many dashboards look fine on a MacBook but are unusable on a 5-inch screen.
State Management and Performance
Zustand for client-side global state (auth user, theme, sidebar state), TanStack Query for server state. Avoid putting server data in Zustand — that leads to stale data bugs. For performance, wrap expensive pure components in React.memo, use useMemo for derived data in table columns, and implement virtualization with @tanstack/react-virtual for tables with thousands of rows. Lazy-load chart components — Recharts adds ~60KB to the bundle, and not every page needs it:
const RevenueChart = dynamic(
() => import('@/components/features/RevenueChart'),
{ ssr: false, loading: () => <ChartSkeleton /> }
);
Connecting to the REST API
Create a typed API client using Axios with an interceptor that attaches the JWT token and handles 401 responses by refreshing the token. Export named functions (not classes) to keep imports clean. Configure the base URL via an environment variable so the same code works in development, staging, and production without changes.
For optimistic updates on mutations — deleting a row, updating a status — use TanStack Query's onMutate callback to update the cache immediately, then roll back on error. This makes the dashboard feel instant even on slow connections.