# How to Build KPI dashboard with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build an executive KPI dashboard with V0 using Next.js, Supabase, and Recharts. Track revenue, churn, MRR, and custom metrics with goal lines, trend indicators, and sparkline charts — all rendered server-side for zero-JS initial load. Takes about 1-2 hours to complete.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Your key metrics defined (e.g., MRR, churn rate, active users, revenue)
- Data source for your KPIs (existing Supabase tables, external APIs, or manual entry)

## Step-by-step guide

### 1. Set up the database schema for KPIs and history

Create the Supabase schema for storing KPI definitions, historical values, and goals. Each KPI has a direction (up = good, down = good) and a target value for goal tracking.

```
// Paste this prompt into V0's AI chat:
// Build a KPI dashboard system. Create a Supabase schema with these tables:
// 1. kpis: id (uuid PK), name (text), slug (text UNIQUE), unit (text like '$', '%', '#'), direction (text CHECK IN 'up','down'), current_value (numeric), target_value (numeric), updated_at (timestamptz)
// 2. kpi_history: id (uuid PK), kpi_id (uuid FK to kpis), value (numeric), period_start (date), period_end (date)
// 3. goals: id (uuid PK), kpi_id (uuid FK to kpis), target (numeric), deadline (date), status (text DEFAULT 'active')
// 4. data_sources: id (uuid PK), name (text), query_template (text), connection_config (jsonb)
// Add RLS policies for authenticated users. Generate SQL and TypeScript types.
```

> Pro tip: Use V0's Connect panel to wire up Supabase in one click. The database URL and anon key are auto-populated in the Vars tab.

**Expected result:** Supabase is connected with all four tables created. KPI definitions, history, goals, and data source configurations are ready.

### 2. Build the main dashboard grid with metric cards

Create the dashboard page showing all KPIs as Cards with large numbers, trend arrows, sparkline charts, and goal progress. This page uses Server Components for fast initial load — the data is fetched server-side with zero client JavaScript.

```
// Paste this prompt into V0's AI chat:
// Create a KPI dashboard at app/kpis/page.tsx.
// Requirements:
// - Fetch all KPIs from Supabase with their latest 7 history values
// - Display as a responsive grid of shadcn/ui Cards (3 columns on desktop, 1 on mobile)
// - Each Card shows: KPI name, current_value in large text with unit, trend arrow (up green or down red based on direction), percentage change from previous period
// - Add a tiny Recharts sparkline (AreaChart, 80px tall) inside each Card showing the last 7 data points
// - Below the value, show a Progress bar toward the target_value
// - Add Tabs at the top for time range: 7d, 30d, 90d
// - Add a Tooltip on hover showing the exact value and date for each sparkline point
// - Use Server Components for the page, wrap sparklines in a 'use client' component
```

> Pro tip: Use V0's Design Mode (Option+D) to adjust card sizes, reorder the grid, and tweak the typography of metric values. Visual adjustments are free — no credits spent.

**Expected result:** The dashboard shows a grid of KPI Cards with large numbers, trend indicators, sparkline charts, and goal progress bars.

### 3. Create the KPI deep-dive page with historical charts

Build a detail page for each KPI showing a full historical chart, goal reference line, and period comparison. Use dynamic routes with the KPI slug for clean URLs.

```
'use client'

import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

interface KpiChartProps {
  data: { date: string; value: number }[]
  target: number
  unit: string
  direction: 'up' | 'down'
}

export function KpiChart({ data, target, unit, direction }: KpiChartProps) {
  const color = direction === 'up' ? '#22c55e' : '#ef4444'

  return (
    <Card>
      <CardHeader>
        <CardTitle>Historical Trend</CardTitle>
      </CardHeader>
      <CardContent>
        <Tabs defaultValue="30d">
          <TabsList>
            <TabsTrigger value="7d">7 Days</TabsTrigger>
            <TabsTrigger value="30d">30 Days</TabsTrigger>
            <TabsTrigger value="90d">90 Days</TabsTrigger>
          </TabsList>
          <TabsContent value="30d">
            <ResponsiveContainer width="100%" height={400}>
              <AreaChart data={data}>
                <CartesianGrid strokeDasharray="3 3" />
                <XAxis dataKey="date" />
                <YAxis tickFormatter={(v) => `${unit}${v}`} />
                <Tooltip formatter={(v: number) => [`${unit}${v}`, 'Value']} />
                <ReferenceLine y={target} stroke="#6366f1" strokeDasharray="5 5" label="Goal" />
                <Area type="monotone" dataKey="value" stroke={color} fill={color} fillOpacity={0.15} />
              </AreaChart>
            </ResponsiveContainer>
          </TabsContent>
        </Tabs>
      </CardContent>
    </Card>
  )
}
```

**Expected result:** The deep-dive page shows a full historical AreaChart with a dashed goal reference line, time range tabs, and formatted tooltips.

### 4. Build the trend calculation RPC function

Create a Supabase database function that computes trend direction and percentage change by comparing the current period to the previous period. This runs server-side via RPC for zero-JS initial load on the dashboard.

```
-- Run this in Supabase SQL Editor
CREATE OR REPLACE FUNCTION get_kpi_trends(p_days int DEFAULT 30)
RETURNS TABLE (
  kpi_id uuid,
  kpi_name text,
  kpi_slug text,
  unit text,
  direction text,
  current_value numeric,
  previous_value numeric,
  target_value numeric,
  delta numeric,
  delta_pct numeric,
  trend text
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    k.id AS kpi_id,
    k.name AS kpi_name,
    k.slug AS kpi_slug,
    k.unit,
    k.direction,
    k.current_value,
    COALESCE(
      (SELECT h.value FROM kpi_history h
       WHERE h.kpi_id = k.id
       ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),
      k.current_value
    ) AS previous_value,
    k.target_value,
    k.current_value - COALESCE(
      (SELECT h.value FROM kpi_history h
       WHERE h.kpi_id = k.id
       ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),
      k.current_value
    ) AS delta,
    CASE
      WHEN COALESCE(
        (SELECT h.value FROM kpi_history h
         WHERE h.kpi_id = k.id
         ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),
        0
      ) = 0 THEN 0
      ELSE ROUND(
        ((k.current_value - (SELECT h.value FROM kpi_history h
         WHERE h.kpi_id = k.id
         ORDER BY h.period_end DESC OFFSET 1 LIMIT 1)) /
        (SELECT h.value FROM kpi_history h
         WHERE h.kpi_id = k.id
         ORDER BY h.period_end DESC OFFSET 1 LIMIT 1)) * 100, 1
      )
    END AS delta_pct,
    CASE
      WHEN k.current_value > COALESCE(
        (SELECT h.value FROM kpi_history h
         WHERE h.kpi_id = k.id
         ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),
        k.current_value
      ) THEN 'up'
      ELSE 'down'
    END AS trend
  FROM kpis k;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```

**Expected result:** The RPC function returns all KPIs with their trend direction, delta, and percentage change. Call it from Server Components with supabase.rpc('get_kpi_trends').

### 5. Set up KPI management and goal CRUD

Build the settings page where users can add, edit, and delete KPI definitions and set goals. Use Server Actions for mutations and react-hook-form with zod for validation.

```
// Paste this prompt into V0's AI chat:
// Create a KPI settings page at app/kpis/settings/page.tsx.
// Requirements:
// - List all KPI definitions in a shadcn/ui Table with columns: Name, Slug, Unit, Direction, Current Value, Target, Actions
// - Add a 'New KPI' Button that opens a Dialog with form fields:
//   - name (Input), slug (Input, auto-generated from name), unit (Select: $, %, #, custom)
//   - direction (RadioGroup: 'Higher is better' / 'Lower is better'), target_value (Input number)
// - Edit button on each row opens the same Dialog pre-filled
// - Delete button with AlertDialog confirmation
// - Goals section below: Table of active goals with KPI name, target, deadline, Progress bar
// - Add Goal button: Select KPI, target number Input, deadline Calendar date picker
// - Use Server Actions for all CRUD operations (insert, update, delete on kpis and goals tables)
```

> Pro tip: Use zod validation in your Server Actions to ensure KPI slugs are URL-safe and target values are positive numbers before inserting into Supabase.

**Expected result:** The settings page allows full CRUD on KPI definitions and goals with form validation and confirmation dialogs.

### 6. Add scheduled KPI refresh and deploy

Set up a Vercel Cron Job that periodically fetches the latest KPI values from your data sources and updates the dashboard. Configure the cron endpoint with secret authentication and deploy.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const authHeader = req.headers.get('Authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { data: sources } = await supabase
    .from('data_sources')
    .select('*, kpis(id, slug)')

  const updates: { kpi_id: string; value: number }[] = []

  for (const source of sources || []) {
    try {
      // Execute the query template to fetch latest value
      const { data } = await supabase.rpc('execute_kpi_query', {
        p_query: source.query_template,
      })
      if (data?.value !== undefined) {
        updates.push({ kpi_id: source.kpis.id, value: data.value })
      }
    } catch (err) {
      console.error(`Failed to refresh ${source.name}:`, err)
    }
  }

  for (const update of updates) {
    await supabase
      .from('kpis')
      .update({ current_value: update.value, updated_at: new Date().toISOString() })
      .eq('id', update.kpi_id)

    await supabase.from('kpi_history').insert({
      kpi_id: update.kpi_id,
      value: update.value,
      period_start: new Date().toISOString().split('T')[0],
      period_end: new Date().toISOString().split('T')[0],
    })
  }

  return NextResponse.json({ refreshed: updates.length })
}
```

**Expected result:** The cron endpoint refreshes all KPI values from configured data sources. Deploy and configure Vercel Cron in vercel.json to run hourly or daily.

## Complete code example

File: `app/api/kpis/refresh/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  // Verify cron secret for security
  const authHeader = req.headers.get('Authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Fetch all KPIs with their current values
  const { data: kpis } = await supabase
    .from('kpis')
    .select('id, slug, current_value')

  const today = new Date().toISOString().split('T')[0]
  let refreshed = 0

  for (const kpi of kpis || []) {
    // Record current value in history
    await supabase.from('kpi_history').upsert(
      {
        kpi_id: kpi.id,
        value: kpi.current_value,
        period_start: today,
        period_end: today,
      },
      { onConflict: 'kpi_id,period_start' }
    )
    refreshed++
  }

  // Update timestamp on all KPIs
  await supabase
    .from('kpis')
    .update({ updated_at: new Date().toISOString() })
    .not('id', 'is', null)

  return NextResponse.json({
    success: true,
    refreshed,
    timestamp: new Date().toISOString(),
  })
}
```

## Common mistakes

- **Calculating trend percentages in client-side JavaScript** — Client-side calculations add unnecessary JavaScript to the bundle, cause layout shifts as data loads, and can show stale values between renders. Fix: Use a Supabase RPC function (get_kpi_trends) to compute deltas and percentages server-side. Call it from a Server Component for zero-JS initial load.
- **Not securing the cron refresh endpoint with a secret** — Without authentication, anyone can trigger the refresh endpoint, potentially causing rate limit issues with external data sources or stale data overwrites. Fix: Add a CRON_SECRET env var in V0's Vars tab and check the Authorization header in the refresh route. Vercel Cron Jobs automatically send this header.
- **Using NEXT_PUBLIC_ prefix for SUPABASE_SERVICE_ROLE_KEY** — The service role key bypasses RLS. Exposing it in the browser lets anyone read all KPI data, including sensitive financial metrics. Fix: Store SUPABASE_SERVICE_ROLE_KEY without any prefix in the Vars tab. Use it only in API routes and Server Components.

## Best practices

- Use Server Components for the dashboard grid — KPI data loads server-side with zero client JavaScript for instant first paint
- Wrap Recharts charts in 'use client' components and keep the parent dashboard page as a Server Component that passes data as props
- Use V0's Design Mode (Option+D) to adjust Card sizes, reorder the dashboard grid, and tweak metric typography without spending credits
- Configure Vercel Cron Jobs in vercel.json for scheduled KPI refresh: {"crons": [{"path": "/api/kpis/refresh", "schedule": "0 * * * *"}]}
- Store KPI history with period_start and period_end dates for flexible time-range queries and accurate trend calculations
- Use Supabase RPC functions for complex calculations like trend direction and delta percentage to avoid redundant logic in multiple components
- Add a CRON_SECRET environment variable and validate it in the refresh endpoint to prevent unauthorized access
- Use Recharts ReferenceLine component to show goal lines on charts — it is a built-in feature that requires no custom drawing code

## Frequently asked questions

### How do I connect my existing data sources to the KPI dashboard?

The data_sources table stores query templates and connection configs. The scheduled refresh endpoint executes these queries via Supabase RPC and updates KPI values. For external services like Stripe or Google Analytics, add API calls in the refresh route using their respective SDKs.

### Can the dashboard update in real-time?

Yes. Use Supabase Realtime to subscribe to changes on the kpis table. When the cron job updates a KPI value, the Realtime subscription pushes the new value to connected clients instantly. Wrap the subscription in a useEffect inside a 'use client' component.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The KPI dashboard has multiple pages (main grid, deep-dive, settings) and chart components that require several prompts. The free tier's limited credits may run out before you finish.

### How does the scheduled refresh work?

Add a crons configuration in vercel.json that hits your /api/kpis/refresh endpoint on a schedule (e.g., hourly). The endpoint is secured with a CRON_SECRET that Vercel sends automatically. It fetches the latest values and updates both the kpis table and kpi_history.

### Can I add custom KPIs without changing code?

Yes. The settings page lets you create new KPI definitions with a name, unit, direction, and target. Once added, the KPI automatically appears on the dashboard and can be updated manually or via the refresh endpoint.

### How do I deploy the KPI dashboard?

Click Share in V0, then Publish to Production. Add a vercel.json file with the crons configuration for scheduled refresh. Set SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and CRON_SECRET in V0's Vars tab before deploying.

### Can RapidDev help build a custom KPI dashboard?

Yes. RapidDev has built over 600 apps including executive dashboards with real-time data connectors, automated reporting, and Slack integrations. Book a free consultation to discuss your metrics and data sources.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/kpi-dashboard
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/kpi-dashboard
