Add an Admin Metric
Add a custom metric card to the admin dashboard. Server-side aggregation via the admin client, no per-request fan-out, super-admin gated.
What you are building
A new metric card on the admin dashboard at /admin, showing a number, a label, and (optionally) a trend indicator. Common asks: total revenue this month, signups in the last 7 days, active users in the last 30 days, conversion rate.
The default admin dashboard uses Server Components to compute metrics on the server. No client-side data fetching, no exposed query patterns, no service_role access leaking to the browser.
The pattern
A metric is a Server Component that:
- Confirms the requester is a super-admin (defense in depth; the admin layout already gates
/admin). - Runs an aggregation query against the database with
createAdminClient(). - Renders the result as a card.
Each metric is independent, runs in parallel (Next.js streams Server Components), and stays self-contained.
Step 1: add the metric function
Add a helper in lib/admin/metrics.ts (create the file if it does not exist):
// lib/admin/metrics.ts
import 'server-only'
import { createAdminClient } from '@/lib/supabase/server'
export async function getRevenueThisMonth() {
const admin = createAdminClient()
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
const { data, error } = await admin
.from('purchases')
.select('amount')
.gte('created_at', startOfMonth)
.eq('status', 'completed')
if (error) {
console.error('Failed to compute revenue:', error)
return { value: 0, error: true }
}
const totalCents = (data || []).reduce((sum, row) => sum + (row.amount || 0), 0)
return { value: totalCents / 100, error: false }
}
export async function getSignupsLast7Days() {
const admin = createAdminClient()
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const { count, error } = await admin
.from('profiles')
.select('*', { count: 'exact', head: true })
.gte('created_at', sevenDaysAgo)
if (error) {
console.error('Failed to count signups:', error)
return { value: 0, error: true }
}
return { value: count || 0, error: false }
}
Three properties of this code are doing the security work:
import 'server-only'at the top. If a Client Component ever tries to import from this file, the build fails. Theservice_rolekey never reaches the browser.createAdminClient()not the browser client. Aggregation queries need to scan rows the requesting user does not "own"; service-role bypasses RLS so the query works.- Errors are logged server-side, not surfaced. The function returns a sentinel value (
0witherror: true); the UI renders gracefully even if the query failed.
Step 2: add the metric card to the admin dashboard
In app/(admin)/admin/page.tsx:
import { getRevenueThisMonth, getSignupsLast7Days } from '@/lib/admin/metrics'
export default async function AdminDashboard() {
const [revenue, signups] = await Promise.all([
getRevenueThisMonth(),
getSignupsLast7Days(),
])
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Revenue this month"
value={revenue.error ? '-' : `$${revenue.value.toFixed(2)}`}
/>
<MetricCard
label="Signups, last 7 days"
value={signups.error ? '-' : String(signups.value)}
/>
</div>
)
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-2 text-3xl font-bold">{value}</p>
</div>
)
}
The Promise.all is the parallelization: both metrics fetch in parallel rather than sequentially. For 4-6 metrics that each take 50-200ms, sequential fetching adds up to noticeable load delay; parallel keeps the page snappy.
Step 3: confirm the admin route is gated
The admin route should already be gated by the admin layout at app/(admin)/layout.tsx, which checks the authenticated user's email against config.admin.superAdminEmails. Confirm this is still wired:
// app/(admin)/layout.tsx (relevant section)
import { redirect } from 'next/navigation'
import { getUser } from '@/lib/supabase/server'
import config from '@/config'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await getUser()
if (!user) {
redirect('/login?next=/admin')
}
if (!config.admin.superAdminEmails.includes(user.email!)) {
redirect('/dashboard')
}
return <>{children}</>
}
This is the primary admin authorization layer. Adding metrics does not bypass it; every render of /admin goes through this check first.
Adding trend indicators
For metrics where direction matters (revenue up vs. down), compare the current period to the previous one:
export async function getRevenueWithTrend() {
const admin = createAdminClient()
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const startOfPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const [current, previous] = await Promise.all([
admin
.from('purchases')
.select('amount')
.gte('created_at', startOfMonth.toISOString())
.eq('status', 'completed'),
admin
.from('purchases')
.select('amount')
.gte('created_at', startOfPrevMonth.toISOString())
.lt('created_at', startOfMonth.toISOString())
.eq('status', 'completed'),
])
const currentCents = (current.data || []).reduce((s, r) => s + (r.amount || 0), 0)
const previousCents = (previous.data || []).reduce((s, r) => s + (r.amount || 0), 0)
const trend = previousCents === 0
? null
: ((currentCents - previousCents) / previousCents) * 100
return {
value: currentCents / 100,
trend, // percent change vs previous month, or null on first month
}
}
Common mistakes
- Fetching from the client. A
useEffect+fetch('/api/admin/metrics')pattern leaks the data shape to the browser and adds an unnecessary round-trip. Server Components fetch once, render the HTML with the data already inline. - Sequential metric queries. Without
Promise.all, 6 metrics × 100ms each = 600ms of waterfall. Parallel = 100-150ms total. - Putting
createAdminClientcalls in Client Components. The build fails (because ofimport 'server-only'), which is the desired outcome; do not delete theserver-onlyimport to "fix" the build. - Doing pagination math in JavaScript instead of SQL. For metrics computed across millions of rows,
count: 'exact'with no filter is expensive. Usehead: trueto skip returning rows, and consider materialized views or scheduled aggregations for very large datasets.
What to read next
- Add a Database Table for the pattern that backs custom metrics tables.
- Backend-only data access for the architectural frame.