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.
What you're building
Startup founders and ops leads need a single view of their most important numbers — revenue, churn rate, MRR, active users — with goal lines and trend indicators. Most dashboard tools cost hundreds per month and require complex setup. You can build one tailored to your metrics in V0.
V0 generates the Card-based dashboard layout, Recharts visualizations, and Supabase queries from natural language prompts. Use the Connect panel for instant database setup and Design Mode to visually adjust card sizes and typography without spending credits.
The architecture uses Server Components for the dashboard grid (zero JavaScript on initial load), a Supabase RPC function for trend calculations, Vercel Cron Jobs for scheduled data refresh, and client components only for interactive charts and time range switching.
Final result
An executive KPI dashboard with metric cards, trend sparklines, goal tracking, historical charts, and automated data refresh via Vercel Cron Jobs.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build a KPI dashboard system. Create a Supabase schema with these tables:3// 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)4// 2. kpi_history: id (uuid PK), kpi_id (uuid FK to kpis), value (numeric), period_start (date), period_end (date)5// 3. goals: id (uuid PK), kpi_id (uuid FK to kpis), target (numeric), deadline (date), status (text DEFAULT 'active')6// 4. data_sources: id (uuid PK), name (text), query_template (text), connection_config (jsonb)7// 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.
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.
1// Paste this prompt into V0's AI chat:2// Create a KPI dashboard at app/kpis/page.tsx.3// Requirements:4// - Fetch all KPIs from Supabase with their latest 7 history values5// - Display as a responsive grid of shadcn/ui Cards (3 columns on desktop, 1 on mobile)6// - 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 period7// - Add a tiny Recharts sparkline (AreaChart, 80px tall) inside each Card showing the last 7 data points8// - Below the value, show a Progress bar toward the target_value9// - Add Tabs at the top for time range: 7d, 30d, 90d10// - Add a Tooltip on hover showing the exact value and date for each sparkline point11// - Use Server Components for the page, wrap sparklines in a 'use client' componentPro 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.
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.
1'use client'23import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer } from 'recharts'4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'5import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'67interface KpiChartProps {8 data: { date: string; value: number }[]9 target: number10 unit: string11 direction: 'up' | 'down'12}1314export function KpiChart({ data, target, unit, direction }: KpiChartProps) {15 const color = direction === 'up' ? '#22c55e' : '#ef4444'1617 return (18 <Card>19 <CardHeader>20 <CardTitle>Historical Trend</CardTitle>21 </CardHeader>22 <CardContent>23 <Tabs defaultValue="30d">24 <TabsList>25 <TabsTrigger value="7d">7 Days</TabsTrigger>26 <TabsTrigger value="30d">30 Days</TabsTrigger>27 <TabsTrigger value="90d">90 Days</TabsTrigger>28 </TabsList>29 <TabsContent value="30d">30 <ResponsiveContainer width="100%" height={400}>31 <AreaChart data={data}>32 <CartesianGrid strokeDasharray="3 3" />33 <XAxis dataKey="date" />34 <YAxis tickFormatter={(v) => `${unit}${v}`} />35 <Tooltip formatter={(v: number) => [`${unit}${v}`, 'Value']} />36 <ReferenceLine y={target} stroke="#6366f1" strokeDasharray="5 5" label="Goal" />37 <Area type="monotone" dataKey="value" stroke={color} fill={color} fillOpacity={0.15} />38 </AreaChart>39 </ResponsiveContainer>40 </TabsContent>41 </Tabs>42 </CardContent>43 </Card>44 )45}Expected result: The deep-dive page shows a full historical AreaChart with a dashed goal reference line, time range tabs, and formatted tooltips.
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.
1-- Run this in Supabase SQL Editor2CREATE OR REPLACE FUNCTION get_kpi_trends(p_days int DEFAULT 30)3RETURNS TABLE (4 kpi_id uuid,5 kpi_name text,6 kpi_slug text,7 unit text,8 direction text,9 current_value numeric,10 previous_value numeric,11 target_value numeric,12 delta numeric,13 delta_pct numeric,14 trend text15) AS $$16BEGIN17 RETURN QUERY18 SELECT19 k.id AS kpi_id,20 k.name AS kpi_name,21 k.slug AS kpi_slug,22 k.unit,23 k.direction,24 k.current_value,25 COALESCE(26 (SELECT h.value FROM kpi_history h27 WHERE h.kpi_id = k.id28 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),29 k.current_value30 ) AS previous_value,31 k.target_value,32 k.current_value - COALESCE(33 (SELECT h.value FROM kpi_history h34 WHERE h.kpi_id = k.id35 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),36 k.current_value37 ) AS delta,38 CASE39 WHEN COALESCE(40 (SELECT h.value FROM kpi_history h41 WHERE h.kpi_id = k.id42 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),43 044 ) = 0 THEN 045 ELSE ROUND(46 ((k.current_value - (SELECT h.value FROM kpi_history h47 WHERE h.kpi_id = k.id48 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1)) /49 (SELECT h.value FROM kpi_history h50 WHERE h.kpi_id = k.id51 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1)) * 100, 152 )53 END AS delta_pct,54 CASE55 WHEN k.current_value > COALESCE(56 (SELECT h.value FROM kpi_history h57 WHERE h.kpi_id = k.id58 ORDER BY h.period_end DESC OFFSET 1 LIMIT 1),59 k.current_value60 ) THEN 'up'61 ELSE 'down'62 END AS trend63 FROM kpis k;64END;65$$ 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').
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.
1// Paste this prompt into V0's AI chat:2// Create a KPI settings page at app/kpis/settings/page.tsx.3// Requirements:4// - List all KPI definitions in a shadcn/ui Table with columns: Name, Slug, Unit, Direction, Current Value, Target, Actions5// - Add a 'New KPI' Button that opens a Dialog with form fields:6// - name (Input), slug (Input, auto-generated from name), unit (Select: $, %, #, custom)7// - direction (RadioGroup: 'Higher is better' / 'Lower is better'), target_value (Input number)8// - Edit button on each row opens the same Dialog pre-filled9// - Delete button with AlertDialog confirmation10// - Goals section below: Table of active goals with KPI name, target, deadline, Progress bar11// - Add Goal button: Select KPI, target number Input, deadline Calendar date picker12// - 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(req: NextRequest) {10 const authHeader = req.headers.get('Authorization')11 if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })13 }1415 const { data: sources } = await supabase16 .from('data_sources')17 .select('*, kpis(id, slug)')1819 const updates: { kpi_id: string; value: number }[] = []2021 for (const source of sources || []) {22 try {23 // Execute the query template to fetch latest value24 const { data } = await supabase.rpc('execute_kpi_query', {25 p_query: source.query_template,26 })27 if (data?.value !== undefined) {28 updates.push({ kpi_id: source.kpis.id, value: data.value })29 }30 } catch (err) {31 console.error(`Failed to refresh ${source.name}:`, err)32 }33 }3435 for (const update of updates) {36 await supabase37 .from('kpis')38 .update({ current_value: update.value, updated_at: new Date().toISOString() })39 .eq('id', update.kpi_id)4041 await supabase.from('kpi_history').insert({42 kpi_id: update.kpi_id,43 value: update.value,44 period_start: new Date().toISOString().split('T')[0],45 period_end: new Date().toISOString().split('T')[0],46 })47 }4849 return NextResponse.json({ refreshed: updates.length })50}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
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function GET(req: NextRequest) {10 // Verify cron secret for security11 const authHeader = req.headers.get('Authorization')12 if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {13 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })14 }1516 // Fetch all KPIs with their current values17 const { data: kpis } = await supabase18 .from('kpis')19 .select('id, slug, current_value')2021 const today = new Date().toISOString().split('T')[0]22 let refreshed = 02324 for (const kpi of kpis || []) {25 // Record current value in history26 await supabase.from('kpi_history').upsert(27 {28 kpi_id: kpi.id,29 value: kpi.current_value,30 period_start: today,31 period_end: today,32 },33 { onConflict: 'kpi_id,period_start' }34 )35 refreshed++36 }3738 // Update timestamp on all KPIs39 await supabase40 .from('kpis')41 .update({ updated_at: new Date().toISOString() })42 .not('id', 'is', null)4344 return NextResponse.json({45 success: true,46 refreshed,47 timestamp: new Date().toISOString(),48 })49}Customization ideas
Slack notifications for goal milestones
Send a Slack message via the Slack API when a KPI crosses its goal threshold, celebrating the win or alerting the team to a negative trend.
PDF report generation
Add a weekly PDF report endpoint using @react-pdf/renderer that captures the current dashboard state with charts and sends it via email.
External data source connectors
Pull KPI values directly from Stripe (MRR, revenue), Google Analytics (users, sessions), or your production database using scheduled API calls.
Team annotations and comments
Allow team members to add text annotations to specific data points on the charts, explaining spikes or dips for historical context.
Embeddable dashboard widgets
Create a public API endpoint that returns KPI Card HTML for embedding individual metrics in Notion, Slack, or other tools via iframe.
Common pitfalls
Pitfall: Calculating trend percentages in client-side JavaScript
How to avoid: 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.
Pitfall: Not securing the cron refresh endpoint with a secret
How to avoid: 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.
Pitfall: Using NEXT_PUBLIC_ prefix for SUPABASE_SERVICE_ROLE_KEY
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a KPI dashboard with Next.js App Router and Supabase. I need a PostgreSQL function called get_kpi_trends that returns all KPIs with their current value, previous period value, delta, percentage change, and trend direction (up/down). The function should compare the most recent kpi_history entry to the one before it. Tables: kpis (id, name, slug, unit, direction, current_value, target_value), kpi_history (kpi_id, value, period_start, period_end). Please write the SQL function.
Create a KPI card component that displays: large metric value with unit prefix, trend arrow icon (ArrowUp or ArrowDown from lucide-react), percentage change Badge (green for positive, red for negative), a Progress bar showing progress toward target, and a tiny Recharts AreaChart sparkline (80px tall, no axes, just the line). The card should accept props: name, value, unit, delta_pct, target, direction, history (array of {date, value}). Make it a Server Component with only the sparkline as a nested 'use client' component.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation