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])
}
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,
},
})
}
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
}
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))
}
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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))
}
}
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
`
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
)
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:
-
Prisma schema —
MrrSnapshotandChurnEventmodels - Webhook sync — aggregate MRR on subscription events, record churn
- Metrics query layer — clean, cacheable functions
- React Server Component — parallel data fetching, admin-gated
-
Recharts —
AreaChartfor MRR,BarChartfor 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)