SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Getting Started
Installation
Configuration
Deployment

Components

  • Hero
  • Pricing
  • Features

Features

  • Authentication
  • Payments
  • Emails
  • Database
  • Blog
  • Security Headers
  • Claude Code Skills

Recipes

  • Add a Server Action
  • Add a Database Table
  • Add an OAuth Provider
  • Add an Email Template
  • Customize the Auth Flow
  • Add an Admin Metric

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:

  1. Confirms the requester is a super-admin (defense in depth; the admin layout already gates /admin).
  2. Runs an aggregation query against the database with createAdminClient().
  3. 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. The service_role key 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 (0 with error: 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 createAdminClient calls in Client Components. The build fails (because of import 'server-only'), which is the desired outcome; do not delete the server-only import 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. Use head: true to 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.