Every SaaS eventually needs a dashboard -- signup trends, revenue charts, user activity. This post walks through the full pattern: aggregate queries with Drizzle ORM, server-side fetching in a Next.js App Router page, and a Recharts area chart that respects dark mode via CSS variables.
Assume a userTable with a created_at column. The goal is to count new signups grouped by day for the last 30 days.
In modules/user/user.repo.ts:
import { db } from '@/db/drizzle';
import { userTable } from '@/db/schema';
import { sql, gte } from 'drizzle-orm';
export async function getSignupsByDay(days: number) {
const since = new Date();
since.setDate(since.getDate() - days);
return db
.select({
date: sql<string>`DATE(${userTable.createdAt})`.as('date'),
count: sql<number>`COUNT(*)::int`.as('count'),
})
.from(userTable)
.where(gte(userTable.createdAt, since))
.groupBy(sql`DATE(${userTable.createdAt})`)
.orderBy(sql`DATE(${userTable.createdAt})`);
}
The ::int cast matters. Drizzle does not narrow COUNT(*) to a number automatically -- without it you get a string at runtime even though TypeScript says number.
SQL GROUP BY DATE(...) only returns rows that exist. A line chart with gaps is confusing. Fill the series in the service layer, not in the query:
In modules/user/user.service.ts:
import { getSignupsByDay } from './user.repo';
export async function getSignupTrend(days = 30) {
const rows = await getSignupsByDay(days);
const map = new Map(rows.map((r) => [r.date, r.count]));
const series: { date: string; signups: number }[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
series.push({ date: key, signups: map.get(key) ?? 0 });
}
return series;
}
Days with no signups become zero, not missing points. The transformation lives in the service -- not in the repo query, not in the component.
The page fetches data at request time and passes it down as a prop. No client-side fetch, no useEffect, no loading spinner on first paint.
app/(main)/dashboard/page.tsx:
import type { Metadata } from 'next';
import { userService } from '@/modules/user';
import { SignupChart } from '@/components/dashboard/SignupChart';
export const metadata: Metadata = {
title: 'Dashboard',
description: 'Account analytics and signup trends.',
};
export default async function DashboardPage() {
const data = await userService.getSignupTrend(30);
return (
<main className="p-6 space-y-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<SignupChart data={data} />
</main>
);
}
Recharts requires "use client" because it uses browser APIs internally. Keep the component focused: accept typed data as a prop and use CSS variables for colors so dark mode works without any theme-detection logic.
components/dashboard/SignupChart.tsx:
'use client';
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface SignupChartProps {
data: { date: string; signups: number }[];
}
export function SignupChart({ data }: SignupChartProps) {
return (
<Card>
<CardHeader>
<CardTitle>Signups -- last 30 days</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<defs>
<linearGradient id="signupGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
className="text-xs text-muted-foreground"
/>
<YAxis
allowDecimals={false}
className="text-xs text-muted-foreground"
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
color: 'hsl(var(--foreground))',
}}
/>
<Area
type="monotone"
dataKey="signups"
stroke="hsl(var(--primary))"
fill="url(#signupGrad)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
A few details worth noting:
hsl(var(--primary)) -- CSS variable references mean the chart inherits your theme and flips correctly in dark mode with no extra logic.className="stroke-border" on CartesianGrid -- Recharts passes className to the SVG element, so Tailwind utility classes work here.allowDecimals={false} on YAxis -- signup counts are integers; fractional tick labels look wrong.tickFormatter={(v) => v.slice(5)} on XAxis -- trims 2026-06-09 to 06-09 so labels fit without overlap on narrow screens.The dashboard shows user-specific data, so guard the page. The cleanest approach is a shared app/(dashboard)/layout.tsx that calls getUserFromRequest and redirects to /login on failure. Every page under the layout is protected with one check instead of repeating it per file.
The same approach works for any aggregate metric: revenue by month (SUM(amount)), active users this week (COUNT(DISTINCT user_id) WHERE last_seen > NOW() - INTERVAL '7 days'), or a pie chart of plan distribution. Keep the SQL in the repo, the series transformation in the service, and the Recharts rendering in a "use client" component. The server component is always the clean boundary that fetches data and hands it to the UI -- no data fetching logic leaks into chart components.