Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Building a SaaS analytics dashboard with Recharts and Drizzle ORM in Next.js App Router

June 9, 2026
nextjsdrizzle-ormrechartssaasneon-db

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.

The repository query

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.

Fill in missing days

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.

Server component fetches, client component renders

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>
  );
}

The Recharts chart component

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.

Protect the route

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.

Extend the pattern

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.