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
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
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.
1Create Supabase Postgres functions for KPI aggregation. Assume these source tables exist:2- orders: id, user_id, total, status ('paid'|'pending'|'refunded'), created_at3- users: id, email, plan ('free'|'pro'|'enterprise'), created_at4- events: id, user_id, name, created_at56Create 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_date82. get_new_users(start_date date, end_date date) RETURNS integer — count of users created between the dates93. get_active_users(start_date date, end_date date) RETURNS integer — count distinct user_id from events between the dates104. 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 decimal1112Grant 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.
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.
1import { useQuery } from '@tanstack/react-query'2import { supabase } from '@/integrations/supabase/client'3import { subDays, format } from 'date-fns'45type PeriodRange = { from: Date; to: Date }67function 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}1415export function useKpiMetric(16 fnName: string,17 range: PeriodRange18) {19 const prev = previousPeriod(range)2021 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 error29 return data as number30 },31 staleTime: 5 * 60_000,32 })3334 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 error42 return data as number43 },44 staleTime: 5 * 60_000,45 })4647 const delta = current.data != null && previous.data48 ? Math.round(((current.data - previous.data) / previous.data) * 100)49 : null5051 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.
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.
1Build a MetricWidget component at src/components/dashboard/MetricWidget.tsx.23Requirements:4- Props: title (string), fnName (string), range (PeriodRange), format ('currency'|'number'|'percent')5- Use the useKpiMetric hook to fetch current, previous, and delta6- Render a shadcn/ui Card with:7 - CardHeader: title, a Skeleton placeholder while loading, and a MoreHorizontal Button for widget options8 - 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 = gray10 - 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 true12- The MoreHorizontal menu should have options: Refresh, Change Period, Remove WidgetExpected 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.
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.
1Add drag-and-drop layout persistence to the dashboard.23Requirements: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/sortable6- Wrap the widget grid in a DndContext + SortableContext from dnd-kit7- Each MetricWidget is wrapped in a useSortable hook to get drag handle props8- 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 grid10- Show a drag handle icon (GripVertical from lucide-react) on each widget card that users click to drag11- Add a skeleton grid layout on first load while the config is fetchingPro 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.
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.
1Add two finishing features to the dashboard:231. 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 type6- Show a 'Live' green dot + 'Updated X seconds ago' text below each metric value7- The timestamp updates every 10 seconds using a setInterval even without a new event892. 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 handles14 - Sets each widget card to break-inside: avoid15 - Sets the page to landscape orientation16 - 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 dateExpected 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
1import { useQuery } from '@tanstack/react-query'2import { supabase } from '@/integrations/supabase/client'3import { subDays, format } from 'date-fns'45export type PeriodRange = { from: Date; to: Date }67function 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}1112function formatDate(d: Date) { return format(d, 'yyyy-MM-dd') }1314export function useKpiMetric(fnName: string, range: PeriodRange) {15 const prev = previousPeriod(range)1617 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 error25 return data as number26 },27 staleTime: 5 * 60_000,28 })2930 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 error38 return data as number39 },40 staleTime: 5 * 60_000,41 })4243 const delta =44 current != null && previous && previous !== 045 ? Math.round(((current - previous) / previous) * 100)46 : null4748 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.
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 }.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation