DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

SaaS Metrics Dashboard in Next.js: MRR, Churn, and Active Users with Recharts

Building a SaaS Metrics Dashboard in Next.js: MRR, Churn, and Active Users with Recharts

If you're running a SaaS, you need three numbers every morning: MRR, churn rate, and active users. Everything else is noise until you have those down cold.

In this guide we'll build a real metrics dashboard in Next.js 16 with:

  • MRR calculated from live Stripe subscription data
  • Churn rate derived from subscription cancellation events
  • Active users queried directly from your database
  • Recharts for clean, responsive visualizations

This is the exact pattern in LaunchKit's admin metrics panel — let's build it from scratch.


The Data Sources

Before writing a line of UI code, understand where each metric lives:

Metric Source Sync Strategy
MRR Stripe subscriptions Webhook → DB
Churn Stripe subscription events Webhook → DB
Active users Your Postgres DB Direct query
Revenue history Stripe invoices Webhook → DB

The key insight: don't call Stripe at render time. Sync from webhooks into your own database, then query locally. Your metrics page should be fast — a page that takes 2 seconds to load because it's calling the Stripe API in real time is a page you'll stop checking.


1. The Prisma Schema for Metrics

Add these models to your schema.prisma:

model MrrSnapshot {
  id        String   @id @default(cuid())
  date      DateTime @unique
  mrr       Int      // in cents
  createdAt DateTime @default(now())

  @@index([date])
}

model ChurnEvent {
  id                   String   @id @default(cuid())
  userId               String
  stripeSubscriptionId String
  canceledAt           DateTime
  mrr                  Int      // MRR lost in cents
  reason               String?  // from Stripe cancellation feedback

  @@index([canceledAt])
}
Enter fullscreen mode Exit fullscreen mode

Run npx prisma migrate dev --name add_metrics.


2. Syncing MRR from Stripe Webhooks

Extend your Stripe webhook handler to capture subscription events:

// lib/webhooks/stripe-metrics.ts
import { prisma } from '@/lib/prisma'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function syncMrrSnapshot() {
  // Fetch all active subscriptions from Stripe
  const subscriptions = await stripe.subscriptions.list({
    status: 'active',
    expand: ['data.items.data.price'],
    limit: 100,
  })

  let totalMrr = 0

  for (const sub of subscriptions.data) {
    for (const item of sub.items.data) {
      const price = item.price
      const amount = price.unit_amount ?? 0

      // Normalize to monthly
      if (price.recurring?.interval === 'year') {
        totalMrr += Math.round(amount / 12)
      } else {
        totalMrr += amount
      }
    }
  }

  // Upsert today's snapshot
  const today = new Date()
  today.setHours(0, 0, 0, 0)

  await prisma.mrrSnapshot.upsert({
    where: { date: today },
    create: { date: today, mrr: totalMrr },
    update: { mrr: totalMrr },
  })

  return totalMrr
}

export async function recordChurnEvent(
  subscription: Stripe.Subscription,
  userId: string
) {
  // Calculate MRR lost
  let lostMrr = 0
  for (const item of subscription.items.data) {
    const amount = item.price.unit_amount ?? 0
    if (item.price.recurring?.interval === 'year') {
      lostMrr += Math.round(amount / 12)
    } else {
      lostMrr += amount
    }
  }

  await prisma.churnEvent.create({
    data: {
      userId,
      stripeSubscriptionId: subscription.id,
      canceledAt: new Date(),
      mrr: lostMrr,
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Wire this into your webhook handler:

// app/api/webhooks/stripe/route.ts (additions)
case 'customer.subscription.deleted': {
  const sub = event.data.object as Stripe.Subscription
  const user = await prisma.user.findFirst({
    where: { stripeSubscriptionId: sub.id },
  })
  if (user) {
    await recordChurnEvent(sub, user.id)
  }
  await syncMrrSnapshot()
  break
}

case 'customer.subscription.created':
case 'customer.subscription.updated': {
  await syncMrrSnapshot()
  break
}
Enter fullscreen mode Exit fullscreen mode

3. The Metrics Query Layer

Create a dedicated file for metrics queries — keep this logic out of your components:

// lib/metrics.ts
import { prisma } from '@/lib/prisma'
import { subDays, startOfDay, endOfDay } from 'date-fns'

export async function getMrrHistory(days = 30) {
  const since = subDays(new Date(), days)

  const snapshots = await prisma.mrrSnapshot.findMany({
    where: { date: { gte: since } },
    orderBy: { date: 'asc' },
  })

  return snapshots.map((s) => ({
    date: s.date.toISOString().split('T')[0],
    mrr: s.mrr / 100, // convert cents to dollars
  }))
}

export async function getCurrentMrr() {
  const latest = await prisma.mrrSnapshot.findFirst({
    orderBy: { date: 'desc' },
  })
  return (latest?.mrr ?? 0) / 100
}

export async function getChurnRate(days = 30) {
  const since = subDays(new Date(), days)

  // Count active subscribers at start of period
  const activeAtStart = await prisma.user.count({
    where: {
      stripeSubscriptionId: { not: null },
      stripeCurrentPeriodEnd: { gte: since },
    },
  })

  // Count churned during period
  const churned = await prisma.churnEvent.count({
    where: { canceledAt: { gte: since } },
  })

  if (activeAtStart === 0) return 0
  return Number(((churned / activeAtStart) * 100).toFixed(1))
}

export async function getActiveUsers(days = 30) {
  const since = subDays(new Date(), days)

  // "Active" = logged in or performed an action in the period
  // Adjust this to match your definition
  const count = await prisma.user.count({
    where: {
      updatedAt: { gte: since },
      deletedAt: null,
    },
  })

  return count
}

export async function getActiveUserHistory(days = 30) {
  // Daily active users for the past N days
  const results: { date: string; users: number }[] = []

  for (let i = days - 1; i >= 0; i--) {
    const day = subDays(new Date(), i)
    const start = startOfDay(day)
    const end = endOfDay(day)

    const count = await prisma.user.count({
      where: {
        updatedAt: { gte: start, lte: end },
        deletedAt: null,
      },
    })

    results.push({
      date: day.toISOString().split('T')[0],
      users: count,
    })
  }

  return results
}

export async function getNewSubscribersHistory(days = 30) {
  const since = subDays(new Date(), days)

  const users = await prisma.user.findMany({
    where: {
      stripeSubscriptionId: { not: null },
      createdAt: { gte: since },
    },
    select: { createdAt: true },
    orderBy: { createdAt: 'asc' },
  })

  // Group by day
  const byDay: Record<string, number> = {}
  for (const user of users) {
    const day = user.createdAt.toISOString().split('T')[0]
    byDay[day] = (byDay[day] ?? 0) + 1
  }

  return Object.entries(byDay)
    .map(([date, count]) => ({ date, count }))
    .sort((a, b) => a.date.localeCompare(b.date))
}
Enter fullscreen mode Exit fullscreen mode

4. The Dashboard Page (React Server Component)

// app/(dashboard)/admin/metrics/page.tsx
import { requireRole } from '@/lib/auth-guard'
import {
  getCurrentMrr,
  getMrrHistory,
  getChurnRate,
  getActiveUsers,
  getActiveUserHistory,
  getNewSubscribersHistory,
} from '@/lib/metrics'
import { MrrChart } from '@/components/metrics/mrr-chart'
import { ActiveUsersChart } from '@/components/metrics/active-users-chart'
import { MetricCard } from '@/components/metrics/metric-card'

export default async function MetricsPage() {
  await requireRole('ADMIN')

  const [mrr, mrrHistory, churnRate, activeUsers, userHistory, newSubs] =
    await Promise.all([
      getCurrentMrr(),
      getMrrHistory(30),
      getChurnRate(30),
      getActiveUsers(30),
      getActiveUserHistory(14),
      getNewSubscribersHistory(30),
    ])

  // MoM MRR change
  const mrrLast = mrrHistory[0]?.mrr ?? mrr
  const mrrChange = mrrLast > 0
    ? Number((((mrr - mrrLast) / mrrLast) * 100).toFixed(1))
    : 0

  return (
    <div className="space-y-8 p-8">
      <div>
        <h1 className="text-2xl font-bold">Metrics</h1>
        <p className="text-sm text-zinc-500 mt-1">Last 30 days</p>
      </div>

      {/* KPI row */}
      <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
        <MetricCard
          label="MRR"
          value={`$${mrr.toLocaleString()}`}
          change={mrrChange}
          suffix="% MoM"
        />
        <MetricCard
          label="Churn Rate"
          value={`${churnRate}%`}
          change={-churnRate}
          suffix="% this month"
          invertColor
        />
        <MetricCard
          label="Active Users"
          value={activeUsers.toLocaleString()}
          change={null}
          suffix="last 30 days"
        />
      </div>

      {/* Charts */}
      <MrrChart data={mrrHistory} />
      <ActiveUsersChart data={userHistory} newSubs={newSubs} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

5. The MetricCard Component

// components/metrics/metric-card.tsx
interface MetricCardProps {
  label: string
  value: string
  change: number | null
  suffix: string
  invertColor?: boolean
}

export function MetricCard({
  label,
  value,
  change,
  suffix,
  invertColor = false,
}: MetricCardProps) {
  const isPositive = invertColor
    ? (change ?? 0) < 0
    : (change ?? 0) >= 0

  return (
    <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
      <p className="text-sm text-zinc-400">{label}</p>
      <p className="mt-1 text-3xl font-bold text-white">{value}</p>
      {change !== null && (
        <p className={`mt-1 text-sm ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
          {change >= 0 ? '+' : ''}{change}{suffix}
        </p>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

6. MRR Chart with Recharts

Install Recharts: npm install recharts

// components/metrics/mrr-chart.tsx
'use client'

import {
  AreaChart,
  Area,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts'

interface MrrChartProps {
  data: { date: string; mrr: number }[]
}

export function MrrChart({ data }: MrrChartProps) {
  return (
    <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
      <h2 className="text-base font-semibold text-white mb-4">MRR (30 days)</h2>
      <ResponsiveContainer width="100%" height={240}>
        <AreaChart data={data}>
          <defs>
            <linearGradient id="mrrGradient" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
            </linearGradient>
          </defs>
          <CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
          <XAxis
            dataKey="date"
            tick={{ fontSize: 11, fill: '#71717a' }}
            tickFormatter={(d) => d.slice(5)} // MM-DD
          />
          <YAxis
            tick={{ fontSize: 11, fill: '#71717a' }}
            tickFormatter={(v) => `$${v}`}
          />
          <Tooltip
            contentStyle={{
              background: '#18181b',
              border: '1px solid #27272a',
              borderRadius: '8px',
              color: '#fff',
            }}
            formatter={(value: number) => [`$${value}`, 'MRR']}
          />
          <Area
            type="monotone"
            dataKey="mrr"
            stroke="#6366f1"
            strokeWidth={2}
            fill="url(#mrrGradient)"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

7. Active Users Chart

// components/metrics/active-users-chart.tsx
'use client'

import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts'

interface ActiveUsersChartProps {
  data: { date: string; users: number }[]
  newSubs: { date: string; count: number }[]
}

export function ActiveUsersChart({ data, newSubs }: ActiveUsersChartProps) {
  // Merge new subscribers into daily data
  const subsByDate = Object.fromEntries(newSubs.map((s) => [s.date, s.count]))
  const merged = data.map((d) => ({
    ...d,
    newSubs: subsByDate[d.date] ?? 0,
  }))

  return (
    <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
      <h2 className="text-base font-semibold text-white mb-4">
        Daily Active Users & New Subscribers (14 days)
      </h2>
      <ResponsiveContainer width="100%" height={240}>
        <BarChart data={merged}>
          <CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
          <XAxis
            dataKey="date"
            tick={{ fontSize: 11, fill: '#71717a' }}
            tickFormatter={(d) => d.slice(5)}
          />
          <YAxis tick={{ fontSize: 11, fill: '#71717a' }} />
          <Tooltip
            contentStyle={{
              background: '#18181b',
              border: '1px solid #27272a',
              borderRadius: '8px',
              color: '#fff',
            }}
          />
          <Bar dataKey="users" fill="#6366f1" radius={[4, 4, 0, 0]} name="Active Users" />
          <Bar dataKey="newSubs" fill="#10b981" radius={[4, 4, 0, 0]} name="New Subscribers" />
        </BarChart>
      </ResponsiveContainer>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Protecting the Metrics Route

The metrics page should only be accessible to admins. Gate it in middleware:

// middleware.ts (additions)
if (pathname.startsWith('/admin')) {
  if (session?.user?.role !== 'ADMIN') {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }
}
Enter fullscreen mode Exit fullscreen mode

Or use the requireRole helper directly in the page (as shown above) for a hard server-side check.


Performance Notes

A few things worth knowing before you ship this:

Snapshot frequency. You don't need to run syncMrrSnapshot on every webhook — just on subscription create/update/delete events. Running it more often is harmless but wasteful.

Historical backfill. If you're adding this to an existing app, you'll have no historical MrrSnapshot data. Write a one-time script to backfill from Stripe invoices using stripe.invoices.list({ limit: 100, starting_after: ... }).

Query optimization. The getActiveUserHistory function runs N queries for N days. For production, replace this with a single aggregation query:

const result = await prisma.$queryRaw`
  SELECT
    DATE(updated_at) as date,
    COUNT(*)::int as users
  FROM users
  WHERE updated_at >= NOW() - INTERVAL '14 days'
    AND deleted_at IS NULL
  GROUP BY DATE(updated_at)
  ORDER BY date ASC
`
Enter fullscreen mode Exit fullscreen mode

Caching. Wrap your metrics queries in unstable_cache (Next.js) or use React's cache function to avoid re-running them on every render:

import { unstable_cache } from 'next/cache'

export const getCachedMrr = unstable_cache(
  getCurrentMrr,
  ['current-mrr'],
  { revalidate: 300 } // 5-minute cache
)
Enter fullscreen mode Exit fullscreen mode

Skip the Build — LaunchKit Has This Built In

Wiring up MRR tracking, churn calculation, and an admin metrics panel takes a meaningful chunk of time to get right — especially when you're simultaneously building the actual product.

LaunchKit ships with a full admin metrics panel pre-built:

  • ✅ MRR snapshots synced from Stripe webhooks
  • ✅ Churn tracking with event log
  • ✅ Active user counts
  • ✅ Recharts visualizations, dark mode, admin-gated route
  • ✅ The full stack: Next.js 16, Prisma, Auth.js v5, Stripe

One-time $49. Yours forever. No recurring fees.

If you're building a SaaS and want this foundation ready on day one, check it out.


Summary

A complete SaaS metrics dashboard in Next.js:

  1. Prisma schemaMrrSnapshot and ChurnEvent models
  2. Webhook sync — aggregate MRR on subscription events, record churn
  3. Metrics query layer — clean, cacheable functions
  4. React Server Component — parallel data fetching, admin-gated
  5. RechartsAreaChart for MRR, BarChart for DAU

Your three morning numbers, sorted. Now go build the product.


Found this useful? Drop a ❤️ and follow for more Next.js SaaS patterns.

Top comments (0)