Every metric on this dashboard follows the same three-file pattern: a lib/metrics/ fetcher → a components/ visualisation → an app/ page. Work through the steps below in order.
1
Open the relevant lib/metrics/ file (e.g. users.ts, engagement.ts) and add a new exported async function. Define a TypeScript interface for the shape of the data it returns.
before
// lib/metrics/users.ts ← an existing metrics file
export interface UserGrowthStats {
totalUsers: number;
newUsersLast30d: number;
// ...
}
export async function getUserGrowthStats(): Promise<UserGrowthStats> {
// TODO: replace with real fetch — see Step 4
return { totalUsers: 2418, newUsersLast30d: 567, ... };
}after — your additions
// lib/metrics/users.ts ← add your new function below
// 1. Define the shape of your data
export interface WeeklySignup {
week: string; // e.g. "2026-W20"
count: number;
}
// 2. Export the async fetcher (mock first, real API later)
export async function getWeeklySignups(): Promise<WeeklySignup[]> {
// TODO: replace with real fetch — see Step 4
return [
{ week: '2026-W18', count: 112 },
{ week: '2026-W19', count: 134 },
{ week: '2026-W20', count: 98 },
];
}All fetcher functions are async and return a typed Promise. Start with mock data so the page renders immediately, then swap in the real API call in Step 4.
2
Add a metric card or chart
For a single number, drop a StatCard directly into the page. For trend data, create a new chart component in components/charts/ and wire it into the page.
Option A — StatCard (single number)
app/users/page.tsx
// app/users/page.tsx — add inside your grid section
import { UserPlus } from 'lucide-react';
import StatCard from '@/components/ui/StatCard';
import { getUserGrowthStats } from '@/lib/metrics/users';
export default async function UsersPage() {
const stats = await getUserGrowthStats();
return (
<section className="grid grid-cols-2 gap-4 xl:grid-cols-4">
<StatCard
label="New Signups This Week"
value={stats.newUsersLast30d}
icon={<UserPlus size={16} />}
change={+15.2} // % vs prior period (optional)
changeLabel="vs last week"
/>
</section>
);
}Option B — Chart component (trend / distribution)
1. Create the chart component:
components/charts/WeeklySignupsChart.tsx
// components/charts/WeeklySignupsChart.tsx
'use client';
import { useEffect, useState } from 'react';
import {
ResponsiveContainer, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip,
} from 'recharts';
import type { WeeklySignup } from '@/lib/metrics/users';
export default function WeeklySignupsChart({ data }: { data: WeeklySignup[] }) {
// Prevents hydration mismatch — always required for Recharts
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="h-64 animate-pulse rounded-lg bg-gray-100" />;
return (
<ResponsiveContainer width="100%" height={256}>
<BarChart data={data} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="count" fill="#0F4C3F" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}2. Import and render it in the page:
app/users/page.tsx
// app/users/page.tsx — wire the chart into the page
import WeeklySignupsChart from '@/components/charts/WeeklySignupsChart';
import { getWeeklySignups } from '@/lib/metrics/users';
export default async function UsersPage() {
const signups = await getWeeklySignups();
return (
<div className="space-y-6">
{/* wrap every chart in this card shell */}
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-800 mb-1">Weekly Signups</h3>
<p className="text-xs text-gray-400 mb-4">New users registered per week</p>
<WeeklySignupsChart data={signups} />
</div>
</div>
);
}Always wrap charts in rounded-xl border border-gray-200 bg-white p-5 shadow-sm to match the existing card style. Include a title and subtitle inside the wrapper before the chart.
3
Create a new section (if needed)
If your metric doesn't belong on any existing page, create a new page and add it to the sidebar nav. Three files, five minutes.
1. Create the page
app/reports/page.tsx ← new file
// 1. Create: app/reports/page.tsx
import { getReportData } from '@/lib/metrics/reports';
import MockDataBanner from '@/components/ui/MockDataBanner';
export default async function ReportsPage() {
const data = await getReportData();
return (
<div className="space-y-6">
<MockDataBanner />
<div>
<h2 className="text-lg font-semibold text-gray-800">Reports</h2>
<p className="mt-0.5 text-sm text-gray-500">
Custom reports and data exports.
</p>
</div>
{/* add your StatCards and charts here */}
</div>
);
}2. Create its metrics file
lib/metrics/reports.ts ← new file
// 2. Create: lib/metrics/reports.ts
export interface ReportData {
totalExports: number;
// add fields as needed
}
export async function getReportData(): Promise<ReportData> {
// TODO: replace with real fetch — see Step 4
return { totalExports: 0 };
}3. Add the nav link
components/layout/Sidebar.tsx ← edit
// 3. Edit: components/layout/Sidebar.tsx
// Add the import at the top:
import { FileText } from 'lucide-react';
// Then add one entry to the navItems array:
const navItems = [
{ label: 'Overview', icon: Home, href: '/' },
{ label: 'Users & Growth', icon: Users, href: '/users' },
{ label: 'Engagement', icon: Activity, href: '/engagement' },
{ label: 'Tree Health', icon: GitBranch, href: '/tree-health' },
{ label: 'Retention', icon: Repeat, href: '/retention' },
{ label: 'Operations', icon: Settings, href: '/operations' },
{ label: 'Reports', icon: FileText, href: '/reports' }, // ← add this
];Pick an icon from lucide-react — browse the full list at lucide.dev/icons. The sidebar handles active-link highlighting automatically.
4
Once the endpoint exists, swap the mock return for a real fetch(). The API base URL and key are already in the environment — never hard-code them.
Replace the mock return in the fetcher
lib/metrics/users.ts
// lib/metrics/users.ts — replace the mock return with a real fetch
export async function getWeeklySignups(): Promise<WeeklySignup[]> {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/users/weekly-signups`,
{
headers: {
'x-api-key': process.env.DASHBOARD_API_KEY!,
},
// Revalidate cache every 5 minutes (Next.js built-in)
next: { revalidate: 300 },
}
);
if (!res.ok) {
throw new Error(`API ${res.status}: ${await res.text()}`);
}
return res.json() as Promise<WeeklySignup[]>;
}Handle errors gracefully in the page
app/users/page.tsx
// app/users/page.tsx — graceful error + empty-state handling
export default async function UsersPage() {
let signups: WeeklySignup[] = [];
try {
signups = await getWeeklySignups();
} catch (err) {
// Log the error; the chart renders empty rather than crashing
console.error('[UsersPage] weekly signups failed:', err);
}
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<WeeklySignupsChart data={signups} />
</div>
</div>
);
}Environment variables
NEXT_PUBLIC_API_BASE_URL = https://beta.molebiapp.com/api
DASHBOARD_API_KEY = (set in .env.local, never committed)
Server-only vars (no NEXT_PUBLIC_ prefix) are safe for the API key — they never reach the browser.
Copy-paste templates
Fully-typed starting points — rename and fill in your data.
// ── StatCard template ─────────────────────────────────────────────────
// Paste inside any page's grid section and fill in your values.
//
// Required imports:
// import { SomeIcon } from 'lucide-react';
// import StatCard from '@/components/ui/StatCard';
<StatCard
label="Your Metric Label" // shown in uppercase above the value
value={yourNumber} // number | string
icon={<SomeIcon size={16} />} // any lucide-react icon
change={changePct} // optional — e.g. +12.5 or -3.1 (percentage)
changeLabel="vs last week" // optional — shown beside the % badge
format="number" // "number" (default) | "decimal"
/>Chart component template — components/charts/MyNewChart.tsx // ── Chart component template ──────────────────────────────────────────
// File: components/charts/MyNewChart.tsx
// Rename MyNewChart and DataPoint to match your metric.
'use client';
import { useEffect, useState } from 'react';
import {
ResponsiveContainer, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip,
} from 'recharts';
interface DataPoint {
label: string;
value: number;
}
function CustomTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-gray-100 bg-white px-3 py-2 shadow-lg text-xs">
<p className="font-medium text-gray-600">{label}</p>
<p className="mt-0.5 font-semibold" style={{ color: '#0F4C3F' }}>
{payload[0].value.toLocaleString('en-US')}
</p>
</div>
);
}
export default function MyNewChart({ data }: { data: DataPoint[] }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="h-64 animate-pulse rounded-lg bg-gray-100" />;
return (
<ResponsiveContainer width="100%" height={256}>
<BarChart data={data} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ stroke: '#E5E7EB' }} />
<Bar dataKey="value" fill="#0F4C3F" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}The golden rule
Every metric lives in three places: lib/metrics/ (data), components/ (UI), and app/ (page). Keeping this consistent means anyone can find, change, or delete any metric in under a minute.