# How to Add an Analytics Dashboard to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

An analytics dashboard needs KPI cards, a Recharts time-series chart, a sortable TanStack Table with CSV export, Supabase aggregation views for the queries, and a role-based access guard so only admins reach the page. With Lovable or V0 you can ship a working dashboard in 4–7 hours for $0/month at small scale. Costs reach $25–50/month at 10K users when event volume forces Supabase Pro and a Redis caching layer.

## What an Analytics Dashboard Actually Is

An analytics dashboard is the single admin screen where founders see whether the app is working: active users, revenue, conversions, and churn laid out as KPI cards and charts. Underneath, it is a set of PostgreSQL aggregation views (daily_active_users, revenue_by_day, conversion_by_funnel_step) queried by a React frontend and visualised with Recharts. The real product decisions are which metrics to surface, what time granularity to use, and — critically — who is allowed to see the data at all. Role-based access guard and the RLS policy on aggregation views are where most first builds have gaps. The Supabase Realtime Presence widget that shows live active user count is the one piece that needs explicit prompt specification to land correctly.

## Anatomy of the Feature

Six components. AI tools generate the chart UI and table well; the aggregation views, materialized refresh, and Realtime Presence layer need explicit specification in the prompt.

- **KPI card row** (ui): React components using shadcn/ui Card that display metric name, current value, a trend arrow (up/down), and the period-over-period delta as a percentage. Data is fetched from Supabase aggregation views. Calculating 'vs previous period' requires two date range queries: one for the current window and one for an equal-length prior window.
- **Time-series chart** (ui): Recharts LineChart (web) or fl_chart package (Flutter mobile) rendering a time-bucketed query from Supabase: SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as dau FROM user_events WHERE created_at BETWEEN startDate AND endDate GROUP BY date ORDER BY date. The date range picker uses react-day-picker or a shadcn/ui DateRangePicker; changing the range reruns the query.
- **Data table with export** (ui): TanStack Table (React) or shadcn/ui DataTable with column sorting, pagination (25 rows default), and a CSV export button. The export uses the papaparse npm package: papaparse.unparse(data) where data is the destructured Supabase query result array — not the raw response object.
- **Supabase aggregation views** (data): PostgreSQL views (non-materialized for datasets under 100K rows; materialized with pg_cron refresh for larger datasets) for daily_active_users, revenue_by_day, and conversion_by_funnel_step. Queried via Supabase JS client using .from('daily_active_users').select(). For performance at scale, a pg_cron job refreshes the materialized view every 5–15 minutes.
- **Realtime active user count** (backend): Supabase Realtime Presence channel ('live-dashboard') tracks users currently in the app. Each connected session calls channel.track({ online_at: new Date().toISOString() }) on a 30-second heartbeat. The dashboard subscribes to join/leave events and updates a counter. A 60-second Presence timeout cleans up stale sessions.
- **Role-based access guard** (backend): Next.js middleware (web) checks the user's role from Supabase user_metadata or a user_roles table before the dashboard page is served. Non-admin users are redirected to /403. FlutterFlow conditional page visibility checks user_roles on page load and navigates away immediately. The Supabase RLS policy on aggregation views provides a second layer: SELECT restricted to EXISTS (SELECT 1 FROM user_roles WHERE user_id = auth.uid() AND role = 'admin').

## Data model

The event ingestion table, aggregation views, and role table. Run this in the Supabase SQL Editor:

```sql
-- Core event ingestion table
create table public.user_events (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete set null,
  event_type text not null,
  properties jsonb default '{}',
  created_at timestamptz not null default now()
);

create index user_events_created_at_idx on public.user_events (created_at desc);
create index user_events_type_created_idx on public.user_events (event_type, created_at desc);
create index user_events_user_created_idx on public.user_events (user_id, created_at desc);

-- Role table
create table public.user_roles (
  user_id uuid references auth.users(id) on delete cascade primary key,
  role text not null default 'user' check (role in ('user', 'admin'))
);

-- Aggregation views
create or replace view public.daily_active_users as
  select
    date(created_at) as date,
    count(distinct user_id) as dau
  from public.user_events
  group by date(created_at)
  order by date(created_at) desc;

create or replace view public.revenue_by_day as
  select
    date(created_at) as date,
    sum((properties->>'amount')::numeric) as revenue
  from public.user_events
  where event_type = 'purchase'
  group by date(created_at)
  order by date(created_at) desc;

create or replace view public.conversion_by_funnel_step as
  select
    event_type,
    count(*) as total_events,
    count(distinct user_id) as unique_users
  from public.user_events
  where event_type in ('signup', 'onboarding_complete', 'first_purchase')
  group by event_type;

-- RLS
alter table public.user_events enable row level security;
alter table public.user_roles enable row level security;

-- Users can insert their own events
create policy "Users can insert own events"
  on public.user_events for insert
  with check (auth.uid() = user_id);

-- Only admins can select all events
create policy "Admins can select all events"
  on public.user_events for select
  using (
    exists (
      select 1 from public.user_roles
      where user_id = auth.uid() and role = 'admin'
    )
  );

-- Users can read their own role
create policy "Users can read own role"
  on public.user_roles for select
  using (auth.uid() = user_id);

-- Materialized view for large datasets (enable after 100K+ rows)
-- create materialized view public.daily_active_users_mat as
--   select date(created_at) as date, count(distinct user_id) as dau
--   from public.user_events group by date(created_at);
-- select cron.schedule('refresh-dau', '*/15 * * * *',
--   'refresh materialized view concurrently public.daily_active_users_mat');
```

The views are readable via Supabase JS using .from('daily_active_users').select(). The RLS policies on user_events propagate to the views — admins see all rows, regular users see none. Switch to the commented materialized view + pg_cron pattern once your user_events table exceeds 100K rows and query latency becomes noticeable.

## Build paths

### Lovable — fit 4/10, 4–7 hours

Lovable generates the Recharts charts, Supabase views, and role guard in React in a single project — best for teams that want a standalone internal admin tool fast.

1. Create a new Lovable project and connect Lovable Cloud so Supabase auth and the database are provisioned automatically
2. Paste the prompt below and let Agent Mode generate the dashboard page, KPI cards, charts, and data table
3. After generation, open Supabase Dashboard → SQL Editor and verify the daily_active_users, revenue_by_day, and conversion_by_funnel_step views exist; if not, paste the SQL from this page and run it
4. Seed a test event via the Supabase Table Editor: insert a row in user_events with your user_id and event_type 'purchase' with properties {"amount": 99}; confirm it appears in the chart
5. Set your user's role to 'admin' in the user_roles table via the Table Editor, then reload the dashboard to confirm the role guard passes
6. Click Publish and share the URL only with admin team members

Starter prompt:

```
Build an admin analytics dashboard page at /admin/dashboard. The page is only visible to users whose role in a Supabase 'user_roles' table equals 'admin'; redirect anyone else to /403. Data comes from Supabase. Layout: 1) KPI card row at the top — show DAU (daily active users), MAU (monthly active users), total revenue this period, and conversion rate (signups → first purchase). Each card shows the metric name, current value, trend arrow, and percentage change vs the previous equal-length period. 2) A Recharts LineChart below showing DAU over time. Above the chart, add a date range picker with preset options (Last 7 days, Last 30 days, Last 90 days, Custom). When the date range changes, all charts and the KPI cards update simultaneously. 3) A data table using shadcn/ui DataTable listing user_events rows with columns: event_type, user_id (truncated to first 8 chars), created_at, and properties as a JSON string. Add column sorting, 25-rows-per-page pagination, and a CSV Export button that downloads the current filtered data using papaparse. 4) A real-time active user count widget with a pulsing green dot — use Supabase Realtime Presence channel 'live-dashboard'; call channel.track({ online_at: new Date().toISOString() }) every 30 seconds. Query dates must be passed as ISO strings (.toISOString()), not Date objects. Empty state: show a friendly message with a chart illustration when no events exist for the selected range.
```

Limitations:

- Materialized view + pg_cron refresh for large datasets must be set up manually in Supabase — Lovable generates the regular views but cannot enable pg_cron
- CSV export via papaparse frequently needs a follow-up prompt to fix the 'empty file' bug caused by not destructuring the Supabase response
- Chart responsiveness on mobile web requires additional Tailwind tweaks — Recharts default sizing often overflows on small screens

### V0 — fit 5/10, 4–7 hours

V0 produces the highest-quality dashboard UI — Recharts charts with perfect shadcn/ui styling, TanStack Table with correct column definitions, and clean Next.js Server Components for data fetching. Best when the dashboard embeds into an existing Next.js app.

1. Prompt V0 with the specification below; it will generate the dashboard page, KPI components, Recharts chart, and TanStack Table
2. In the V0 Vars panel, add your Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in the Supabase SQL Editor to create user_events, user_roles, and the aggregation views
4. Add the admin role check to Next.js middleware manually: create middleware.ts at the project root that reads the Supabase session and checks user_roles for role = 'admin'
5. Publish to Vercel and seed test events in the Supabase Table Editor to confirm charts render correctly

Starter prompt:

```
Build a Next.js admin analytics dashboard page at /admin/dashboard using shadcn/ui and Recharts. Components needed: 1) KpiCard — displays label, value (formatted number), trend direction (up/down arrow), and period delta percentage. Fetch DAU, MAU, revenue, and conversion rate from Supabase views: daily_active_users (columns: date, dau), revenue_by_day (columns: date, revenue), conversion_by_funnel_step (columns: event_type, unique_users). Calculate period-over-period delta by running two date range queries. 2) TimeSeriesChart — a Recharts LineChart with a responsive container showing DAU over the selected date range. Include a DateRangePicker built with react-day-picker; on range change call a Server Action to re-fetch. Pass all dates as ISO strings (.toISOString()) to Supabase queries. 3) EventsTable — a TanStack Table with columns: event_type, user_id (first 8 chars), created_at (formatted), properties (JSON string). Add sorting, 25-row pagination, and a CSV Export button using papaparse.unparse(data) where data is the destructured Supabase query array. 4) ActiveUserCount — a client component subscribed to Supabase Realtime Presence channel 'live-dashboard'; heartbeat every 30 seconds; display as pulsing green dot + count. Role guard: check user_roles in Next.js middleware; redirect non-admins to /403. Empty state: centered illustration and 'No events yet' message when data array is empty.
```

Limitations:

- V0 does not auto-provision Supabase — all env vars must be set manually in Vercel Dashboard and the SQL schema run in Supabase SQL Editor before data appears
- Realtime Presence for the live user count requires a custom useEffect that V0 does not generate automatically — expect to add it in a follow-up prompt
- Admin role middleware must be wired manually; V0 generates the check function but does not add it to middleware.ts

### Flutterflow — fit 3/10, 5–9 hours

FlutterFlow with fl_chart produces smooth native mobile charts and works well for simple KPI dashboards on mobile; complex multi-chart layouts with date pickers are harder to compose in the visual builder.

1. In FlutterFlow, create an AdminDashboard page; add a conditional visibility rule that checks user_roles.role = 'admin' via a Supabase Backend Query on page load; if role is not 'admin', navigate to a 403 page
2. Add a Row of KPI stat cards using a Container + Text layout; wire each to a Supabase Backend Query on the daily_active_users or revenue_by_day view with a date range filter
3. Add an fl_chart LineChart widget from the Flutter Package Manager; map chart data points to the Supabase view query results (date on X axis, dau on Y axis)
4. Add a Supabase Backend Query on user_events for the data list; use a ListView with pagination (25 items per page) and a dropdown for sorting by created_at or event_type
5. For CSV export, add a custom Dart action block that converts the query results to CSV format using the csv Flutter package and triggers a file download via the path_provider + share_plus packages

Limitations:

- Complex multi-chart dashboards with synchronized date range pickers are difficult to compose in FlutterFlow's visual builder — each chart needs its own Backend Query wired to the same date range state variable
- CSV export is not available natively in FlutterFlow; it requires a custom Dart action block that most teams need a developer to write
- FlutterFlow Pro plan ($70/month) is required to export the custom Dart code for packaging and deployment
- Recharts and TanStack Table — the best-in-class web libraries for this feature — are not available in Flutter; fl_chart is capable but has less documentation and community support

### Custom — fit 3/10, 2–3 weeks

Custom development is worth it when the dashboard must embed third-party BI tools, handle multi-tenant data isolation, or serve a high-write event stream that needs a streaming data warehouse.

1. Choose a BI embedding path: Metabase (self-hosted, MIT license) or Redash embed iframes for non-technical stakeholders who need drag-and-drop chart builders
2. For high-write event streams (>1K events/second), replace Supabase as the analytics store with ClickHouse or Tinybird — both support sub-second aggregation queries at millions of events
3. Build per-customer data isolation with Postgres row-level security policies on a tenant_id column across all event tables; ensure aggregation views include the tenant_id filter
4. Add Upstash Redis caching for expensive aggregation queries — cache results for 5 minutes to avoid hitting Supabase on every dashboard open

Limitations:

- Metabase and Redash embedding require a self-hosted deployment on a VPS or cloud VM — adds infrastructure cost and maintenance overhead
- ClickHouse or Tinybird replaces Supabase as the analytics store only — auth, user management, and application data remain in Supabase; syncing events between the two adds a data pipeline step

## Gotchas

- **Charts show no data for the selected date range** — The Supabase query uses .gte('created_at', startDate) where startDate is a JavaScript Date object. The Supabase JS client does not auto-serialize Date objects to ISO strings — the comparison fails silently and returns zero rows, making the chart appear empty even when data exists. Fix: Always convert date values before passing to Supabase: .gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString()). Verify by logging the query URL before the fetch — the date parameter should be an ISO 8601 string like '2026-01-01T00:00:00.000Z'.
- **Realtime active user count is inflated** — Supabase Presence tracks presence keys but does not automatically clean up stale entries when a browser tab is closed without triggering the window unload event (common on mobile browsers and when the user force-quits). The count climbs over the day and never comes down. Fix: Set a heartbeat interval: call channel.track({ online_at: new Date().toISOString() }) every 30 seconds from a setInterval. Set the Presence timeout to 60 seconds in the Realtime channel config so any session that misses two heartbeats is evicted automatically.
- **Non-admin users access the dashboard URL directly** — The AI-generated role check is a conditional render in the React component — the dashboard JSX is not rendered, but the data fetch in a useEffect or Server Component still runs for any authenticated user who navigates to the URL. The aggregation views return data to the requester. Fix: Add a Supabase RLS policy on the aggregation views restricting SELECT to users with role = 'admin' in user_roles. Also add Next.js middleware (middleware.ts) that reads the session and checks the role before serving the page. Both layers are needed — UI guards are trivially bypassed.
- **CSV export downloads an empty file** — The papaparse.unparse() call receives the raw Supabase response object (which includes data, error, status, and count properties) rather than the data array. papaparse serializes the object shape, not the rows — the resulting CSV has column headers from the response object, not from the event data. Fix: Destructure the query result before passing to papaparse: const { data, error } = await supabase.from('user_events').select(); if (data) papaparse.unparse(data). Never pass the raw response object.
- **Date range picker causes infinite re-render** — The date range is stored as JavaScript Date objects in React state. The useEffect that re-fetches data has [startDate, endDate] as dependencies. Date objects are compared by reference — every render creates a new Date object, so the dependency array is never considered stable, and the fetch fires in an infinite loop. Fix: Store date range values as ISO strings (primitives) in state rather than Date objects. Convert to Date for the picker UI only: const [startISO, setStartISO] = useState('2026-01-01T00:00:00.000Z'). ISO strings compare by value, not reference, so the useEffect dependency check is stable.

## Best practices

- Pass all dates to Supabase as ISO strings (.toISOString()), never as JavaScript Date objects — the client does not auto-serialize them and silent mismatch produces empty charts
- Add RLS policies to your aggregation views, not just to the underlying tables — views inherit table RLS only when using the security_invoker option; verify with a non-admin user session
- Switch to materialized views once user_events exceeds 100K rows; unguarded GROUP BY queries on large tables will noticeably slow the dashboard for every admin who opens it
- Set a 30-second Presence heartbeat and a 60-second server-side timeout for the live user count — without these, force-closed browser tabs permanently inflate the count
- Seed your own test events via the Supabase Table Editor before showing the dashboard to stakeholders — an empty chart on first demo kills confidence even if the feature is working correctly
- Cache expensive aggregation query results in Upstash Redis for 5 minutes — most admins check the dashboard multiple times per session and the underlying data does not change second-to-second
- Add an empty state component for every chart and KPI card: 'No data for this period' with a helpful suggestion is better than a blank or broken chart

## Frequently asked questions

### Can I embed this dashboard in an iframe for client-facing reporting?

Yes, but add X-Frame-Options: SAMEORIGIN or a Content-Security-Policy frame-ancestors directive to control which domains can embed it. For external clients, generate a short-lived signed URL or use a separate public-facing report page with pre-aggregated data so client users cannot adjust the URL to access raw event data.

### How do I add a date range filter that works across all charts simultaneously?

Store the date range as a pair of ISO strings in a shared React context or a state management layer (Zustand, Jotai). Each chart component reads from the same context. When the date picker updates the context values, every chart re-fetches with the new range. Store the dates as ISO strings in state to prevent the infinite re-render loop that Date objects cause.

### Can non-admin users see a limited version of the dashboard with only their own data?

Yes. Create a separate /dashboard/me route (not /admin/dashboard) with a different set of Supabase queries that include auth.uid() = user_id in every filter. The RLS policies on user_events already restrict regular users to their own rows, so the data layer is safe. Build a simplified KPI card set showing only the current user's activity and stats.

### How do I export the data table to Excel?

Use the xlsx npm package instead of or alongside papaparse: import { utils, writeFile } from 'xlsx'; const ws = utils.json_to_sheet(data); const wb = utils.book_new(); utils.book_append_sheet(wb, ws, 'Events'); writeFile(wb, 'events.xlsx'). This produces a .xlsx file that Excel opens natively, with proper column widths and type inference.

### Will the dashboard slow down my app if it runs heavy aggregate queries?

It will not affect the user-facing app because Supabase queries run in the database and the results go to the admin browser — they do not share compute with user requests. At 10K+ users where user_events has millions of rows, switch to materialized views refreshed by pg_cron every 15 minutes. Cache the results in Upstash Redis for 5 minutes. The dashboard page load should stay under 1 second.

### How do I add a chart comparing this month to last month?

Run two Supabase queries in parallel: one for the current month (created_at BETWEEN start_of_this_month AND now()) and one for the previous month (created_at BETWEEN start_of_last_month AND end_of_last_month). Pass both result sets to Recharts as two separate Line elements in the same LineChart with different stroke colors and a legend. The KPI card delta already does this calculation — reuse the same pattern for the chart.

### Can I send a weekly summary email with the key metrics automatically?

Yes. Create a Supabase Edge Function that queries the aggregation views for the past 7 days, formats the results as an HTML email, and sends it via Resend. Schedule the Edge Function to run every Monday at 9 AM UTC using Supabase pg_cron: SELECT cron.schedule('weekly-analytics-email', '0 9 * * 1', 'SELECT net.http_post(...)') . This gives every admin a weekly digest without them needing to open the dashboard.

### How do I add a funnel chart showing drop-off between steps?

Use Recharts FunnelChart with data built from the conversion_by_funnel_step view: the unique_users count at each step divided by the count at the first step gives the retention percentage. Define the steps as constants in your code (signup → onboarding_complete → first_purchase) and map each step to the view's event_type column. Recharts renders the narrowing funnel automatically from the decreasing value array.

---

Source: https://www.rapidevelopers.com/app-features/analytics-dashboard
© RapidDev — https://www.rapidevelopers.com/app-features/analytics-dashboard
