Dashboards are one of the most common web applications — from analytics platforms to admin panels to internal tools. Next.js is ideal for dashboards because server components handle data-heavy pages without shipping large JavaScript bundles, and the App Router's nested layouts keep your sidebar and navigation persistent across pages. This guide builds a complete analytics dashboard with charts, data tables, server-side filtering, and role-based access control.
Prerequisites
- →Node.js 20+
Required for Next.js and its build tools.
- →PostgreSQL Database
For storing dashboard data, user accounts, and analytics. Neon offers a generous free tier.
- →React and TypeScript Knowledge
Familiarity with React hooks, component composition, and TypeScript interfaces.
Scaffold the Project and Install Dashboard Dependencies
Create a Next.js project and install libraries for charts, data tables, and database access. Recharts provides composable React chart components, and TanStack Table gives you headless data table primitives that you style with Tailwind. Prisma handles all database queries with type safety.
npx create-next-app@latest my-dashboard \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir
cd my-dashboard
npm install recharts @tanstack/react-table @prisma/client next-auth@beta
npm install -D prismaTip: Recharts is the most popular React charting library — it uses SVG rendering for crisp visuals at any resolution.
Tip: TanStack Table is headless, meaning you control all the markup and styling.
Create the Dashboard Layout with Sidebar Navigation
Build a persistent layout with a collapsible sidebar, top navigation bar, and content area. Next.js App Router layouts persist across page navigations — the sidebar does not re-render when you switch between dashboard pages. This gives the dashboard a native app feel.
// src/app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/Sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) redirect("/login");
return (
<div className="flex h-screen bg-gray-50">
<Sidebar
user={{
name: session.user?.name ?? "User",
email: session.user?.email ?? "",
role: session.user?.role ?? "viewer",
}}
items={[
{ label: "Overview", href: "/dashboard", icon: "home" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "chart" },
{ label: "Users", href: "/dashboard/users", icon: "users" },
{ label: "Settings", href: "/dashboard/settings", icon: "settings" },
]}
/>
<main className="flex-1 overflow-y-auto p-8">{children}</main>
</div>
);
}Tip: Use a server component for the layout to fetch the user session without client-side JavaScript.
Tip: Store sidebar collapse state in localStorage via a client component wrapper.
Build the Overview Page with KPI Cards
Create the main dashboard page with key performance indicator cards that show metrics like total revenue, active users, conversion rate, and growth percentage. Each card fetches its data from the database using a server component — no loading states needed because the data is available before the HTML reaches the browser.
// src/app/dashboard/page.tsx
import { prisma } from "@/lib/prisma";
async function getMetrics() {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const [totalUsers, newUsers, totalRevenue, activeUsers] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.payment.aggregate({ _sum: { amount: true } }),
prisma.session.count({
where: { lastActive: { gte: thirtyDaysAgo } },
}),
]);
return { totalUsers, newUsers, totalRevenue: totalRevenue._sum.amount ?? 0, activeUsers };
}
export default async function DashboardPage() {
const metrics = await getMetrics();
const cards = [
{ label: "Total Users", value: metrics.totalUsers.toLocaleString(), change: "+12%" },
{ label: "New Users (30d)", value: metrics.newUsers.toLocaleString(), change: "+8%" },
{ label: "Revenue", value: `$${(metrics.totalRevenue / 100).toLocaleString()}`, change: "+23%" },
{ label: "Active Users", value: metrics.activeUsers.toLocaleString(), change: "+5%" },
];
return (
<div>
<h1 className="text-2xl font-bold">Overview</h1>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<div key={card.label} className="rounded-xl border bg-white p-6">
<p className="text-sm text-gray-500">{card.label}</p>
<p className="mt-2 text-3xl font-bold">{card.value}</p>
<p className="mt-1 text-sm text-green-600">{card.change}</p>
</div>
))}
</div>
</div>
);
}Tip: Use Promise.all to run independent database queries in parallel — this cuts the page load time significantly.
Tip: Calculate percentage changes by comparing current period to previous period.
Add Interactive Charts with Recharts
Build a chart component that visualizes time-series data like revenue over time, user signups, or page views. Recharts requires client-side rendering for interactivity (tooltips, hover effects), so mark it as a client component. The data is still fetched on the server and passed as props.
// src/components/RevenueChart.tsx
"use client";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface DataPoint {
date: string;
revenue: number;
}
export function RevenueChart({ data }: { data: DataPoint[] }) {
return (
<div className="rounded-xl border bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Revenue Over Time</h3>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
formatter={(value: number) => [`$${value}`, "Revenue"]}
labelStyle={{ fontWeight: "bold" }}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#2563eb"
fill="#3b82f6"
fillOpacity={0.1}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}Tip: Use ResponsiveContainer to make charts resize automatically with the viewport.
Tip: Keep chart components as client components but fetch and aggregate data in the parent server component.
Build a Data Table with Server-Side Pagination
Create a sortable, paginated data table using TanStack Table and URL search params for server-side pagination. Storing pagination state in the URL means table pages are shareable and work with the browser's back button. The server component fetches only the current page of data from the database.
// src/app/dashboard/users/page.tsx
import { prisma } from "@/lib/prisma";
import { UsersTable } from "@/components/UsersTable";
const PAGE_SIZE = 20;
export default async function UsersPage({
searchParams,
}: {
searchParams: { page?: string; sort?: string; order?: string };
}) {
const page = Math.max(1, Number(searchParams.page) || 1);
const sortField = searchParams.sort ?? "createdAt";
const sortOrder = searchParams.order === "asc" ? "asc" : "desc";
const [users, totalCount] = await Promise.all([
prisma.user.findMany({
skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
orderBy: { [sortField]: sortOrder },
select: {
id: true,
name: true,
email: true,
createdAt: true,
organization: { select: { name: true } },
},
}),
prisma.user.count(),
]);
return (
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Users</h1>
<p className="text-sm text-gray-500">{totalCount} total</p>
</div>
<div className="mt-6">
<UsersTable
users={users}
currentPage={page}
totalPages={Math.ceil(totalCount / PAGE_SIZE)}
sortField={sortField}
sortOrder={sortOrder}
/>
</div>
</div>
);
}Tip: Server-side pagination keeps your queries fast even with millions of rows — you only fetch one page at a time.
Tip: Use Prisma's count and findMany in a Promise.all for parallel execution.
Implement Role-Based Access Control
Add role-based access control so admins can manage users and settings while viewers can only see analytics. Create a middleware layer that checks the user's role on every request and a utility function for component-level permission checks. Roles are stored in the database and cached in the session.
// src/lib/permissions.ts
export type Role = "admin" | "editor" | "viewer";
const roleHierarchy: Record<Role, number> = {
viewer: 0,
editor: 1,
admin: 2,
};
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
}
export function requireRole(userRole: Role, requiredRole: Role) {
if (!hasPermission(userRole, requiredRole)) {
throw new Error(`Requires ${requiredRole} role, but user has ${userRole}`);
}
}
// Usage in a server component or API route:
// const session = await auth();
// requireRole(session.user.role, "admin");
// ... proceed with admin-only operationsTip: Use a role hierarchy so admin inherits all editor permissions, and editor inherits viewer permissions.
Tip: Check permissions in server components and API routes — never rely on client-side role checks alone.
Add Real-Time Updates with Server-Sent Events
Implement real-time dashboard updates using Server-Sent Events (SSE) through a Next.js route handler. SSE is simpler than WebSockets for one-way data streams like live metrics. The client receives updates automatically and re-renders the affected components without polling.
// src/app/api/dashboard/stream/route.ts
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
export async function GET() {
const session = await auth();
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (data: Record<string, unknown>) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
// Send initial data
const activeUsers = await prisma.session.count({
where: {
lastActive: {
gte: new Date(Date.now() - 5 * 60 * 1000),
},
},
});
sendEvent({ type: "activeUsers", value: activeUsers });
// Send updates every 10 seconds
const interval = setInterval(async () => {
try {
const count = await prisma.session.count({
where: {
lastActive: {
gte: new Date(Date.now() - 5 * 60 * 1000),
},
},
});
sendEvent({ type: "activeUsers", value: count });
} catch {
clearInterval(interval);
controller.close();
}
}, 10_000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Tip: SSE automatically reconnects if the connection drops — the browser handles this natively.
Tip: Use a 10-30 second interval for dashboard updates to keep database load manageable.
Deploy with Caching and Performance Optimization
Deploy the dashboard to Vercel with optimized caching strategies. Dashboard pages with real-time data use dynamic rendering, while settings and documentation pages use static generation. Use React Suspense boundaries for streaming so the page shell loads instantly while data-heavy sections load progressively.
// Example: Dashboard page with streaming
import { Suspense } from "react";
import { KPICards } from "@/components/KPICards";
import { RevenueChart } from "@/components/RevenueChart";
import { RecentActivity } from "@/components/RecentActivity";
export const dynamic = "force-dynamic";
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Overview</h1>
<Suspense fallback={<KPICardsSkeleton />}>
<KPICards />
</Suspense>
<div className="grid gap-6 lg:grid-cols-2">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
);
}
function KPICardsSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-28 animate-pulse rounded-xl border bg-gray-100" />
))}
</div>
);
}
function ChartSkeleton() {
return <div className="h-80 animate-pulse rounded-xl border bg-gray-100" />;
}
function ActivitySkeleton() {
return <div className="h-80 animate-pulse rounded-xl border bg-gray-100" />;
}Tip: Use Suspense boundaries to stream heavy components — the page shell renders immediately while charts load.
Tip: Create skeleton components that match the dimensions of their real counterparts to prevent layout shift.
Tip: Use 'force-dynamic' on pages that show real-time data, and let Next.js statically generate pages that rarely change.
Next Steps
- →Add data export functionality (CSV, PDF) for reports and analytics.
- →Implement audit logging to track who changed what and when.
- →Build a notification system with in-app alerts and email digests.
- →Add keyboard shortcuts for power users to navigate the dashboard faster.