Feature spec
IntermediateCategory
analytics-admin
Build with AI
4–8 hours with Lovable or V0
Custom build
2–3 weeks custom dev
Running cost
$0/mo up to 100 users · $25–50/mo at 10K users
Works on
Everything it takes to ship an Analytics Dashboard — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- KPI card row at the top showing totals (DAU, MAU, revenue, conversion rate) with period-over-period trend arrows and percentage change
- Time-series line chart with a date range picker offering 7d, 30d, 90d, and a custom range — all charts update simultaneously when the date range changes
- Data table with sortable columns, pagination, and a CSV export button that downloads the currently filtered view
- Real-time active user count displayed as a pulsing green dot with a live number, updating without page refresh
- Breakdown charts showing distribution by device type, country, or feature with bar or pie chart variants
- Role-based access control so non-admin users who navigate to the dashboard URL are redirected, not just hidden from the nav
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
UIReact 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.
Note: Trend direction is computed client-side: ((current - previous) / previous) * 100. Guard against division by zero when previous period has no data.
Time-series chart
UIRecharts 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.
Note: Pass startDate and endDate as ISO strings (.toISOString()), not JavaScript Date objects — the Supabase JS client does not auto-serialize Date.
Data table with export
UITanStack 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.
Note: Destructure before passing to papaparse: const { data } = await supabase.from('view').select(); then papaparse.unparse(data). Passing the raw response object produces an empty CSV file.
Supabase aggregation views
DataPostgreSQL 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.
Note: Materialized views do not emit Supabase Realtime events. If near-real-time counts are needed, use a separate non-materialized view or the Presence channel for live data.
Realtime active user count
BackendSupabase 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.
Note: Without the heartbeat, browser tabs closed without a clean unload event inflate the count. Set heartbeat interval to 30 seconds and Presence timeout to 60 seconds.
Role-based access guard
BackendNext.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').
Note: UI-only role checks are insufficient — the data API must also enforce the role restriction or direct API calls return data to any authenticated user.
The data model
The event ingestion table, aggregation views, and role table. Run this in the Supabase SQL Editor:
1-- Core event ingestion table2create table public.user_events (3 id uuid primary key default gen_random_uuid(),4 user_id uuid references auth.users(id) on delete set null,5 event_type text not null,6 properties jsonb default '{}',7 created_at timestamptz not null default now()8);910create index user_events_created_at_idx on public.user_events (created_at desc);11create index user_events_type_created_idx on public.user_events (event_type, created_at desc);12create index user_events_user_created_idx on public.user_events (user_id, created_at desc);1314-- Role table15create table public.user_roles (16 user_id uuid references auth.users(id) on delete cascade primary key,17 role text not null default 'user' check (role in ('user', 'admin'))18);1920-- Aggregation views21create or replace view public.daily_active_users as22 select23 date(created_at) as date,24 count(distinct user_id) as dau25 from public.user_events26 group by date(created_at)27 order by date(created_at) desc;2829create or replace view public.revenue_by_day as30 select31 date(created_at) as date,32 sum((properties->>'amount')::numeric) as revenue33 from public.user_events34 where event_type = 'purchase'35 group by date(created_at)36 order by date(created_at) desc;3738create or replace view public.conversion_by_funnel_step as39 select40 event_type,41 count(*) as total_events,42 count(distinct user_id) as unique_users43 from public.user_events44 where event_type in ('signup', 'onboarding_complete', 'first_purchase')45 group by event_type;4647-- RLS48alter table public.user_events enable row level security;49alter table public.user_roles enable row level security;5051-- Users can insert their own events52create policy "Users can insert own events"53 on public.user_events for insert54 with check (auth.uid() = user_id);5556-- Only admins can select all events57create policy "Admins can select all events"58 on public.user_events for select59 using (60 exists (61 select 1 from public.user_roles62 where user_id = auth.uid() and role = 'admin'63 )64 );6566-- Users can read their own role67create policy "Users can read own role"68 on public.user_roles for select69 using (auth.uid() = user_id);7071-- Materialized view for large datasets (enable after 100K+ rows)72-- create materialized view public.daily_active_users_mat as73-- select date(created_at) as date, count(distinct user_id) as dau74-- from public.user_events group by date(created_at);75-- select cron.schedule('refresh-dau', '*/15 * * * *',76-- 'refresh materialized view concurrently public.daily_active_users_mat');Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Prompt V0 with the specification below; it will generate the dashboard page, KPI components, Recharts chart, and TanStack Table
- 2In the V0 Vars panel, add your Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
- 3Run the SQL schema from this page in the Supabase SQL Editor to create user_events, user_roles, and the aggregation views
- 4Add 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'
- 5Publish to Vercel and seed test events in the Supabase Table Editor to confirm charts render correctly
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.
Where this path bites
- 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
Third-party services you'll need
The dashboard runs on free and MIT-licensed tools. Costs appear only when event volume forces a Pro database tier or a caching layer.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Event logging, aggregation views, Realtime Presence for live user count, role-based access | Free: 500MB DB, 200 Realtime connections, 2 projects | Pro $25/mo (8GB DB, 500 Realtime connections) |
| Recharts | Time-series and breakdown charts in React; MIT license | Free (npm package, no API key) | Free |
| papaparse | CSV export of data table; MIT license | Free (npm package, no API key) | Free |
| Upstash Redis | Cache aggregation query results to reduce Supabase load at high event volume | Free: 10K commands/day | Pro $0.2/100K commands (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier handles event storage and view queries. Recharts and papaparse are free npm packages. Realtime Presence within free 200-connection limit.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Charts show no data for the selected date range
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI build paths cover standard admin dashboards well. These scenarios require custom engineering:
- Dashboard must be white-labelled and multi-tenant — each customer sees only their own data with per-customer data isolation enforced at the query level, not just via UI filtering
- Chart data must be certified for financial reporting — audit-grade data provenance requires immutable event logs and a data lineage trail that Supabase views alone cannot provide
- Real-time sub-second updates from a high-write event stream (>1,000 events/second) require a streaming data warehouse like ClickHouse or Tinybird — Supabase Realtime and standard PostgreSQL cannot aggregate at that throughput
- Custom drill-down from a summary KPI to an individual user-level event log with under 200ms response time requires query optimisation and caching that exceeds what aggregation views alone can deliver
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds an analytics dashboard into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.