Skip to main content
RapidDev - Software Development Agency

How to Build a Kpi Dashboard with Lovable

Build an executive KPI dashboard in Lovable with draggable metric widgets, Postgres aggregation functions, date-range filtering, trend indicators, period comparison charts, and Supabase Realtime updates. Non-technical founders get a polished, live command center for their business metrics without writing any server-side code.

What you'll build

  • Supabase Postgres functions that aggregate KPI metrics on demand
  • Draggable KPI widget cards using a grid layout with persisted positions
  • Period comparison charts showing current vs previous period side by side
  • A widget type registry supporting metric, chart, and table widget types
  • Supabase Realtime badge on each widget showing last-updated timestamp
  • User-level dashboard configuration persisted in Supabase
  • One-click PDF export of the dashboard using the browser's print API
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read2–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build an executive KPI dashboard in Lovable with draggable metric widgets, Postgres aggregation functions, date-range filtering, trend indicators, period comparison charts, and Supabase Realtime updates. Non-technical founders get a polished, live command center for their business metrics without writing any server-side code.

What you're building

An executive KPI dashboard in Lovable lets founders and managers see every critical metric in one place. Each metric is backed by a Postgres function in Supabase that accepts a date range and returns the aggregated number — making it trivial to add new KPIs without changing the React code.

Widgets live in a draggable grid. When you reorder them, the new positions are saved to a dashboard_configs table in Supabase so your layout persists across sessions and devices. Each widget can be one of three types: metric (single number with delta), chart (line or bar trend), or table (top-N rows).

Period comparison is built into every metric widget. A Select in the widget header lets you switch between 7d, 30d, 90d, and custom date ranges. Choosing a range re-fires the Postgres function for both the current and previous equivalent period, and the widget renders a small sparkline comparing the two.

Final result

A personalized, draggable KPI dashboard with period comparison, real-time updates, and a print-to-PDF export — accessible from your Lovable publish URL.

Tech stack

LovableFrontend
SupabaseDatabase, Functions & Realtime
RechartsCharts inside widgets
shadcn/uiUI Components
dnd-kitDrag-and-drop widget grid
date-fnsPeriod calculation

Prerequisites

  • Lovable Pro account
  • Supabase project with existing business data tables (orders, users, events, etc.)
  • VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in Cloud tab → Secrets
  • A list of 4–8 metrics you want to track (e.g. MRR, signups, conversion rate)
  • Basic familiarity with Lovable's Cloud tab and the Publish flow

Build steps

1

Create Postgres aggregation functions for each KPI

Postgres functions accept date range parameters and return a single aggregated value. This pattern lets you call any KPI from React with a single RPC call and swap the time period dynamically.

prompt.txt
1Create Supabase Postgres functions for KPI aggregation. Assume these source tables exist:
2- orders: id, user_id, total, status ('paid'|'pending'|'refunded'), created_at
3- users: id, email, plan ('free'|'pro'|'enterprise'), created_at
4- events: id, user_id, name, created_at
5
6Create these SQL functions:
71. get_mrr(start_date date, end_date date) RETURNS numeric sum of orders.total where status='paid' and created_at between start_date and end_date
82. get_new_users(start_date date, end_date date) RETURNS integer count of users created between the dates
93. get_active_users(start_date date, end_date date) RETURNS integer count distinct user_id from events between the dates
104. get_conversion_rate(start_date date, end_date date) RETURNS numeric (count of users with plan != 'free') / (count of all users) * 100, rounded to 1 decimal
11
12Grant EXECUTE on all functions to the authenticated role.

Pro tip: Add default parameter values to each function (e.g. start_date date DEFAULT (now() - interval '30 days')) so you can call them without parameters during development and testing in the Supabase SQL editor.

Expected result: Running SELECT get_mrr('2024-01-01', '2024-01-31') in the Supabase SQL editor returns a numeric value. All four functions are callable with any date range.

2

Build the widget data hook with period comparison

Create a React hook that calls a Postgres function for both the current period and the equivalent previous period, and returns the values, delta percentage, and trend data.

src/hooks/useKpiMetric.ts
1import { useQuery } from '@tanstack/react-query'
2import { supabase } from '@/integrations/supabase/client'
3import { subDays, format } from 'date-fns'
4
5type PeriodRange = { from: Date; to: Date }
6
7function previousPeriod(range: PeriodRange): PeriodRange {
8 const days = Math.round((range.to.getTime() - range.from.getTime()) / 86_400_000)
9 return {
10 from: subDays(range.from, days),
11 to: subDays(range.to, days),
12 }
13}
14
15export function useKpiMetric(
16 fnName: string,
17 range: PeriodRange
18) {
19 const prev = previousPeriod(range)
20
21 const current = useQuery({
22 queryKey: [fnName, 'current', range],
23 queryFn: async () => {
24 const { data, error } = await supabase.rpc(fnName, {
25 start_date: format(range.from, 'yyyy-MM-dd'),
26 end_date: format(range.to, 'yyyy-MM-dd'),
27 })
28 if (error) throw error
29 return data as number
30 },
31 staleTime: 5 * 60_000,
32 })
33
34 const previous = useQuery({
35 queryKey: [fnName, 'previous', prev],
36 queryFn: async () => {
37 const { data, error } = await supabase.rpc(fnName, {
38 start_date: format(prev.from, 'yyyy-MM-dd'),
39 end_date: format(prev.to, 'yyyy-MM-dd'),
40 })
41 if (error) throw error
42 return data as number
43 },
44 staleTime: 5 * 60_000,
45 })
46
47 const delta = current.data != null && previous.data
48 ? Math.round(((current.data - previous.data) / previous.data) * 100)
49 : null
50
51 return { current: current.data, previous: previous.data, delta, isLoading: current.isLoading }
52}

Pro tip: Cache RPC calls with a 5-minute staleTime — KPI aggregations are expensive queries and executives rarely need sub-minute freshness for monthly metrics.

Expected result: Calling useKpiMetric('get_mrr', { from, to }) returns the current MRR, previous period MRR, and the delta percentage — all ready to render in a widget card.

3

Build the metric widget card with sparkline

Create a reusable widget card component that displays the KPI value, period delta badge, and a small comparison sparkline using Recharts.

prompt.txt
1Build a MetricWidget component at src/components/dashboard/MetricWidget.tsx.
2
3Requirements:
4- Props: title (string), fnName (string), range (PeriodRange), format ('currency'|'number'|'percent')
5- Use the useKpiMetric hook to fetch current, previous, and delta
6- Render a shadcn/ui Card with:
7 - CardHeader: title, a Skeleton placeholder while loading, and a MoreHorizontal Button for widget options
8 - Large formatted value (use Intl.NumberFormat for currency and percent)
9 - Delta Badge: positive delta = green with TrendingUp icon, negative = red with TrendingDown icon, zero = gray
10 - A small comparison BarChart (height=60) with two bars: current (indigo) and previous (gray). Suppress axes and tooltips for the mini chart.
11- Show a Skeleton for the entire card while isLoading is true
12- The MoreHorizontal menu should have options: Refresh, Change Period, Remove Widget

Expected result: A polished metric card renders showing the formatted KPI value, a colored delta badge, and a compact two-bar comparison chart. Loading state shows a Skeleton.

4

Add a draggable widget grid

Wrap all widgets in a drag-and-drop grid using dnd-kit. When widgets are reordered, save the new order to a dashboard_configs table in Supabase so the layout persists.

prompt.txt
1Add drag-and-drop layout persistence to the dashboard.
2
3Requirements:
4- Create a dashboard_configs table in Supabase: id, user_id, widget_order (jsonb array of widget ids), created_at. RLS: users can read/write their own row only.
5- Install @dnd-kit/core and @dnd-kit/sortable
6- Wrap the widget grid in a DndContext + SortableContext from dnd-kit
7- Each MetricWidget is wrapped in a useSortable hook to get drag handle props
8- On drag end, update the local widget order state immediately (optimistic), then call supabase.from('dashboard_configs').upsert({ user_id: userId, widget_order: newOrder })
9- On mount, fetch the saved widget_order and apply it to the grid
10- Show a drag handle icon (GripVertical from lucide-react) on each widget card that users click to drag
11- Add a skeleton grid layout on first load while the config is fetching

Pro tip: Use dnd-kit's rectSortingStrategy for a grid layout rather than verticalListSortingStrategy — it handles multi-column grids correctly and produces more natural reorder animations.

Expected result: Widgets can be dragged to any position in the grid. After reordering, refreshing the page restores the saved layout from Supabase.

5

Add Realtime freshness indicators and PDF export

Subscribe to a Supabase Realtime channel to show a live 'Last updated' badge on each widget. Add a Print to PDF button that triggers the browser's native print dialog with a print-optimized stylesheet.

prompt.txt
1Add two finishing features to the dashboard:
2
31. Realtime freshness indicator:
4- Subscribe to the dashboard_events Realtime channel (reuse from other components if it exists)
5- When a new event fires, record the current timestamp in a Map keyed by widget type
6- Show a 'Live' green dot + 'Updated X seconds ago' text below each metric value
7- The timestamp updates every 10 seconds using a setInterval even without a new event
8
92. PDF export:
10- Add an Export PDF Button in the dashboard top bar (use Printer icon from lucide-react)
11- On click, call window.print()
12- Add a global CSS print stylesheet (@media print) that:
13 - Hides the top navigation, filter controls, and drag handles
14 - Sets each widget card to break-inside: avoid
15 - Sets the page to landscape orientation
16 - Adds a print timestamp footer using CSS content: 'Exported ' attr(data-date)
17- Set data-date on the dashboard container using a useEffect that formats the current date

Expected result: Each widget shows a green live dot that updates its timestamp periodically. Clicking Export PDF opens a clean print dialog with the dashboard laid out for A4 landscape.

Complete code

src/hooks/useKpiMetric.ts
1import { useQuery } from '@tanstack/react-query'
2import { supabase } from '@/integrations/supabase/client'
3import { subDays, format } from 'date-fns'
4
5export type PeriodRange = { from: Date; to: Date }
6
7function previousPeriod(range: PeriodRange): PeriodRange {
8 const days = Math.round((range.to.getTime() - range.from.getTime()) / 86_400_000)
9 return { from: subDays(range.from, days), to: subDays(range.to, days) }
10}
11
12function formatDate(d: Date) { return format(d, 'yyyy-MM-dd') }
13
14export function useKpiMetric(fnName: string, range: PeriodRange) {
15 const prev = previousPeriod(range)
16
17 const { data: current, isLoading } = useQuery({
18 queryKey: ['kpi', fnName, 'current', formatDate(range.from), formatDate(range.to)],
19 queryFn: async () => {
20 const { data, error } = await supabase.rpc(fnName, {
21 start_date: formatDate(range.from),
22 end_date: formatDate(range.to),
23 })
24 if (error) throw error
25 return data as number
26 },
27 staleTime: 5 * 60_000,
28 })
29
30 const { data: previous } = useQuery({
31 queryKey: ['kpi', fnName, 'previous', formatDate(prev.from), formatDate(prev.to)],
32 queryFn: async () => {
33 const { data, error } = await supabase.rpc(fnName, {
34 start_date: formatDate(prev.from),
35 end_date: formatDate(prev.to),
36 })
37 if (error) throw error
38 return data as number
39 },
40 staleTime: 5 * 60_000,
41 })
42
43 const delta =
44 current != null && previous && previous !== 0
45 ? Math.round(((current - previous) / previous) * 100)
46 : null
47
48 return { current: current ?? null, previous: previous ?? null, delta, isLoading }
49}

Customization ideas

Widget marketplace

Build an 'Add Widget' drawer that shows all available widget types (metric, chart, table) with previews. Users pick widgets to add to their dashboard. Store the enabled widget list in dashboard_configs.

Shared team dashboard

Add a shared_dashboard boolean to dashboard_configs. When true, all members of the same org see the same layout. Individual users can switch to personal mode to customize without affecting the team view.

Goal tracking overlay

Add a kpi_goals table where managers set monthly targets per metric. Each MetricWidget shows a progress bar from 0% to goal. Metrics that exceed the target show a trophy icon.

Slack digest integration

Create a Supabase Edge Function that calls all Postgres KPI functions, formats the results as a Slack block kit message, and posts to a channel webhook URL stored in Secrets — triggered by a pg_cron job every Monday morning.

Drill-down popover

Clicking a metric value opens a Popover with a full-height chart for that metric over the last 90 days, so executives can investigate a trend without navigating away from the dashboard.

Custom date range comparison

Add a DatePickerWithRange to each widget header allowing custom from/to dates for the current period. The hook automatically computes the equivalent prior period for comparison.

Common pitfalls

Pitfall: Calling Postgres aggregation functions on every render without caching

How to avoid: Set staleTime: 5 * 60_000 on KPI metric queries — five-minute caching is fresh enough for executive dashboards.

Pitfall: Storing widget layout in localStorage instead of Supabase

How to avoid: Persist the widget order in a dashboard_configs Supabase table with the user_id as the key.

Pitfall: Using dnd-kit's verticalListSortingStrategy in a multi-column grid

How to avoid: Use rectSortingStrategy from @dnd-kit/sortable for grid layouts.

Pitfall: Not granting EXECUTE on Postgres functions to the authenticated role

How to avoid: Add GRANT EXECUTE ON FUNCTION function_name TO authenticated after creating each function.

Best practices

  • Create one Postgres function per KPI metric — small, focused functions are easier to test and debug than a single large function that returns all metrics.
  • Use SECURITY DEFINER on aggregation functions so they bypass RLS and return correct totals even when source table policies are restrictive.
  • Always pair SECURITY DEFINER functions with explicit user checks inside the function body to prevent unauthorized access.
  • Use Intl.NumberFormat for currency and percentage formatting — it handles locale-specific separators and currency symbols correctly without a library.
  • Index the date columns (created_at, order_date) and status columns used in KPI function WHERE clauses to keep aggregations fast.
  • Store the selected period in URL search params so users can bookmark and share specific time windows with colleagues.
  • Use CSS @media print to create a clean PDF export without third-party PDF libraries — the browser handles pagination and print layout.
  • Validate that Postgres functions return NULL-safe values — use COALESCE(SUM(total), 0) rather than SUM(total) to prevent null widget displays.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I have Supabase Postgres functions that accept start_date and end_date parameters and return a single aggregated number. Help me write a React hook called useKpiMetric that calls supabase.rpc() for both the current period and the previous equivalent period using React Query, computes the percentage delta, and returns { current, previous, delta, isLoading }.

Lovable Prompt

Add a new widget type to the KPI dashboard: a leaderboard table widget. It should accept a Postgres function name and a date range, call the function (which returns an array of { name, value } rows), and render a ranked list with position numbers, avatars, names, and formatted values. Support both ascending and descending sort orders configured in the widget settings.

Build Prompt

In Lovable, add a dashboard onboarding wizard that runs the first time a user visits. Step 1: choose which metrics to show (checkboxes for MRR, users, conversion, etc.). Step 2: pick a color theme. Step 3: set a default date range. Save the choices to dashboard_configs in Supabase and never show the wizard again after completion.

Frequently asked questions

Do I need existing data to get started?

No. Ask Lovable to create mock source tables with seed data as part of the initial prompt. This lets you see the dashboard rendering real numbers immediately while you work on the UI, then point the functions at your real tables when ready.

How do I add a new KPI metric to the dashboard?

Create a new Postgres function following the same signature (start_date, end_date parameters, returns numeric). Then add a new MetricWidget to the dashboard grid with the function name as a prop. No changes to the hook are needed.

Why are my Postgres functions returning null instead of 0?

SQL aggregate functions like SUM and COUNT return NULL when there are no matching rows. Wrap the return expression in COALESCE: RETURN COALESCE(SUM(total), 0). This ensures widgets always display a number.

Can I show hourly data instead of daily aggregates?

Yes. Modify the Postgres functions to group by date_trunc('hour', created_at) and return an array of rows instead of a single number. Use a chart widget type instead of a metric widget type to render the hourly breakdown.

How does the drag-and-drop layout affect mobile users?

dnd-kit supports touch events on mobile. However, dragging in a grid on a small screen can conflict with scroll gestures. Consider disabling drag-and-drop on screens narrower than 768px and using a fixed stacked layout for mobile instead.

Can multiple team members share the same dashboard layout?

Yes. Add a shared_dashboard boolean column to dashboard_configs and use org_id as the key instead of user_id when it is true. All users in the org read the same config row. Admins can toggle the dashboard between personal and shared modes.

Why does my PDF export cut charts in the middle of the page?

Add break-inside: avoid to each widget card's print CSS. This tells the browser not to split any card across a page break. Also set print-color-adjust: exact on the root so chart colors are preserved in the PDF.

Can RapidDev help me design custom Postgres aggregation functions for my specific data model?

Yes. RapidDev can review your Supabase schema and write optimized aggregation functions for your specific KPIs, including multi-table joins and custom business logic.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.