Feature spec
AdvancedCategory
analytics-admin
Build with AI
2-4 days with Lovable or V0 + Supabase
Custom build
2-4 weeks custom dev
Running cost
$0/mo up to 100 users · $25/mo up to 1K users
Works on
Everything it takes to ship User Activity Analytics — parts, prompts, and real costs.
User activity analytics means an event ingestion endpoint, a Supabase events table with materialized views for cohorts and funnels, and an admin-only dashboard built with Next.js + Recharts or Lovable + shadcn/ui. You can ship a working system in 2–4 days using V0 or Lovable for $0/month at launch. The two hardest parts are keeping the dashboard admin-only (RLS on the events table) and keeping materialized views current (pg_cron refresh every hour).
What User Activity Analytics Actually Is
User activity analytics is an internal system that records every meaningful thing users do — page views, button clicks, feature opens, session starts — and then aggregates that data into a dashboard only admins can see. Unlike third-party tools (PostHog, Mixpanel), building it yourself means zero per-event fees, full data ownership, and the ability to query anything about your own users without paying for a seat license. The architecture has four layers: a tracking endpoint (Supabase Edge Function) that receives events from your app; an events table with materialized views for daily active users, retention cohorts, and funnel conversion; an admin dashboard UI built with Recharts and shadcn/ui DataTable; and access controls (Supabase RLS with an admin role check) that ensure regular users can never read each other's behavior. The most expensive mistake is skipping the RLS layer — AI tools routinely generate event queries with the anon key and no role filter, making all user data readable by anyone who is logged in.
What users consider table stakes in 2026
- Real-time or near-real-time active user count on the dashboard homepage showing users active in the last 5 minutes
- Event funnel visualization with step-by-step conversion rates so admins can see exactly where users drop off during onboarding or checkout
- Retention cohort table showing day 1, day 7, and day 30 retention percentages for each signup week
- Per-user activity timeline that an admin can look up by email address to see exactly what a specific user did and when
- Filterable date range picker with presets (last 7d, 30d, 90d) and a custom range option, defaulting to last 30 days
- CSV export of raw event data for the selected date range, downloadable directly from the dashboard
Anatomy of the Feature
Six components. V0 or Lovable builds the dashboard UI and event ingestion correctly on the first prompt. The materialized views and pg_cron schedule are what must be configured manually in Supabase — AI tools cannot reach the Supabase Dashboard to set these up for you.
Event ingestion layer
BackendA Supabase Edge Function (Deno) deployed at POST /track receives event payloads with {event_name, user_id, session_id, properties, timestamp}. It validates the payload, batches inserts into the events table using the Supabase service_role key, and rate-limits ingestion per user via an Upstash Redis counter (free tier). Never use the anon key for server-side writes to the events table.
Note: Rate limiting prevents abuse: a single misbehaving client firing 10K events/second would bloat the events table and drive up Supabase costs. The Upstash Redis free tier (10K commands/day) covers this for typical apps.
Events table and materialized views
DataThe events table stores every raw event. Three materialized views aggregate it: mv_daily_active_users (distinct user_ids per day), mv_retention_cohorts (first-event date bucketed by week, with day 1/7/30 counts), and mv_funnel_steps (event sequence counts for a configurable funnel). Views are refreshed every hour via a pg_cron job. All three are indexed on (user_id, event_name, created_at) for fast dashboard queries.
Note: Materialized view refresh takes CPU time proportional to events table size. At 10M+ rows, consider adding a partial index on created_at for the last 90 days and archiving older events to cold storage.
Admin dashboard UI
UIA Next.js Server Component page (V0 path) or Lovable React page protected by a Supabase Auth role check. Uses Recharts for line charts (DAU trend), bar charts (event frequency), and FunnelChart (conversion funnel). The cohort retention table uses shadcn/ui DataTable. The date range picker uses react-day-picker with presets. All data is fetched server-side via Supabase RPC calls to avoid exposing the service_role key on the client.
Note: Server Components in Next.js mean cohort data never passes through the browser — the page renders with data already populated, which is critical for admin pages that should not expose query logic client-side.
Funnel builder
UIA drag-and-drop ordered list where the admin selects event names (from a dropdown populated by SELECT DISTINCT event_name from events) and defines the funnel sequence. On submit, the UI calls a Supabase RPC function calculate_funnel(event_array text[], start_date date, end_date date) that returns step counts and conversion rates. The result renders as a Recharts FunnelChart with percentage labels between steps.
Note: The calculate_funnel RPC function must be written manually in Supabase SQL editor. It uses a series of CTEs with lag() to identify users who completed each step in order within a session window.
Real-time active user counter
BackendA Supabase Realtime channel subscription on the events table (INSERT events only) increments an in-memory count on the admin dashboard. A sliding window query — SELECT COUNT(DISTINCT user_id) FROM events WHERE created_at > now() - interval '5 minutes' — provides the initial count on page load and serves as a fallback when the Realtime subscription disconnects.
Note: Realtime subscriptions disconnect after ~1 minute of browser inactivity. Implement reconnect logic: on the visibilitychange event, re-subscribe and re-fetch the REST fallback count.
CSV export
ServiceA Next.js Server Action (or Supabase Edge Function for non-Next.js builds) runs a parameterized query against the events table for the selected date range and streams the result as a CSV file using Papa Parse on the client. The export respects the same admin-only RLS as the dashboard — the service_role key is used server-side only.
Note: For large exports (>100K rows), stream the response using a ReadableStream rather than loading the full result set into memory. Papa Parse's unparse() handles the column header row automatically.
The data model
Two core tables plus three materialized views. Run this in the Supabase SQL Editor (Dashboard → SQL Editor → New query). The RLS policies on the events table are the most critical part — get them wrong and all user behavior data is readable by any logged-in user.
1-- Core events table2create table public.events (3 id uuid primary key default gen_random_uuid(),4 user_id uuid references auth.users(id) on delete cascade not null,5 session_id uuid not null,6 event_name text not null,7 properties jsonb not null default '{}',8 created_at timestamptz not null default now()9);1011-- User sessions table12create table public.user_sessions (13 id uuid primary key default gen_random_uuid(),14 user_id uuid references auth.users(id) on delete cascade not null,15 started_at timestamptz not null default now(),16 ended_at timestamptz,17 page_count int not null default 0,18 platform text check (platform in ('web', 'ios', 'android'))19);2021-- RLS22alter table public.events enable row level security;23alter table public.user_sessions enable row level security;2425-- Events: authenticated users can insert their own events26create policy "Users can insert own events"27 on public.events for insert28 with check (auth.uid() = user_id);2930-- Events: only admins can read any events31create policy "Admins can read all events"32 on public.events for select33 using (auth.jwt() ->> 'role' = 'admin');3435-- Sessions: users can insert and update their own sessions36create policy "Users can manage own sessions"37 on public.user_sessions for all38 using (auth.uid() = user_id)39 with check (auth.uid() = user_id);4041-- Sessions: only admins can read any session42create policy "Admins can read all sessions"43 on public.user_sessions for select44 using (auth.jwt() ->> 'role' = 'admin');4546-- Indexes for dashboard query performance47create index events_user_event_time_idx48 on public.events (user_id, event_name, created_at desc);4950create index events_created_at_idx51 on public.events (created_at desc);5253create index events_session_idx54 on public.events (session_id, event_name);5556-- Materialized view: daily active users57create materialized view public.mv_daily_active_users as58 select59 date_trunc('day', created_at) as day,60 count(distinct user_id) as dau61 from public.events62 where created_at >= now() - interval '90 days'63 group by 164 order by 1 desc;6566create unique index mv_dau_day_idx on public.mv_daily_active_users (day);6768-- Materialized view: retention cohorts (day 0 = first event)69create materialized view public.mv_retention_cohorts as70 with first_events as (71 select user_id, min(created_at) as cohort_start72 from public.events73 group by user_id74 ),75 cohort_activity as (76 select77 fe.user_id,78 date_trunc('week', fe.cohort_start) as cohort_week,79 (e.created_at::date - fe.cohort_start::date) as days_since_start80 from first_events fe81 join public.events e on e.user_id = fe.user_id82 )83 select84 cohort_week,85 count(distinct user_id) as cohort_size,86 count(distinct case when days_since_start between 1 and 1 then user_id end) as day_1,87 count(distinct case when days_since_start between 7 and 7 then user_id end) as day_7,88 count(distinct case when days_since_start between 30 and 30 then user_id end) as day_3089 from cohort_activity90 group by cohort_week91 order by cohort_week desc;9293create unique index mv_cohorts_week_idx on public.mv_retention_cohorts (cohort_week);9495-- Materialized view: last_refreshed tracking96create table public.mv_refresh_log (97 view_name text primary key,98 last_refreshed_at timestamptz default now()99);100101insert into public.mv_refresh_log (view_name) values102 ('mv_daily_active_users'),103 ('mv_retention_cohorts');104105-- RPC function to refresh all views (callable from dashboard 'Refresh now' button)106create or replace function public.refresh_analytics_views()107returns void108language plpgsql109security definer110as $$111begin112 -- Only admins can call this113 if auth.jwt() ->> 'role' != 'admin' then114 raise exception 'Admin only';115 end if;116 refresh materialized view concurrently public.mv_daily_active_users;117 refresh materialized view concurrently public.mv_retention_cohorts;118 update public.mv_refresh_log set last_refreshed_at = now();119end;120$$;121122-- RPC function for funnel calculation123create or replace function public.calculate_funnel(124 event_array text[],125 start_date date,126 end_date date127)128returns table(step_name text, step_index int, user_count bigint, conversion_rate numeric)129language plpgsql130security definer131as $$132declare133 i int;134 step text;135begin136 -- Only admins can call this137 if auth.jwt() ->> 'role' != 'admin' then138 raise exception 'Admin only';139 end if;140141 for i in 1..array_length(event_array, 1) loop142 step := event_array[i];143 return query144 select145 step as step_name,146 i as step_index,147 count(distinct e.user_id) as user_count,148 round(149 count(distinct e.user_id)::numeric /150 nullif((select count(distinct user_id) from public.events151 where event_name = event_array[1]152 and created_at::date between start_date and end_date), 0) * 100,153 1154 ) as conversion_rate155 from public.events e156 where e.event_name = step157 and e.created_at::date between start_date and end_date158 and (i = 1 or exists (159 select 1 from public.events prev160 where prev.user_id = e.user_id161 and prev.event_name = event_array[i - 1]162 and prev.created_at < e.created_at163 ));164 end loop;165end;166$$;Heads up: After running this SQL, enable pg_cron in the Supabase Dashboard under Database → Extensions, then add a cron job: select cron.schedule('refresh-analytics', '0 * * * *', 'select refresh_analytics_views()'); — this runs the refresh every hour. The admin role in the RLS policy is a custom JWT claim; set it via a Supabase Auth hook or an Edge Function that updates the user's app_metadata when you promote them to admin.
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.
Correct choice for production-grade analytics: Next.js admin panel with Supabase materialized views, ClickHouse for high-volume event storage, and PostHog self-hosted for behavioral event querying. The right call when event volume exceeds 1M/day or compliance requirements demand it.
Step by step
- 1Next.js admin panel with Supabase as the primary database: materialized views for cohorts, pg_cron for hourly refresh, service_role key used server-side only in Server Components
- 2Event ingestion via a Supabase Edge Function with Upstash Redis rate limiting; at 1M+ events/day, migrate the events table to ClickHouse (ClickHouse Cloud from $0 on free tier, then $0.006/GB ingested) for OLAP queries without blocking Postgres writes
- 3Self-hosted PostHog (free) for event-level querying, funnel analysis, and cohort building — Postgres materialized views are replaced by PostHog's native query engine
- 4GDPR right-to-erasure: a deletion pipeline that cascades deletes across events, user_sessions, and PostHog using the PostHog Persons API — tested under audit with a staging dataset
Where this path bites
- Significant upfront engineering cost — 2-4 weeks vs 2-4 days for an AI scaffold
- ClickHouse migration from Postgres requires a dual-write period and backfill — non-trivial operational work
- Self-hosted PostHog requires dedicated infrastructure and monitoring; a lapsed instance means losing event history
Third-party services you'll need
The feature runs entirely on open-source or free-tier infrastructure at launch scale. Third-party analytics fees only appear if you add PostHog Cloud above the free tier.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Events table, materialized views, pg_cron refresh, Edge Function for event ingestion, Realtime for live counter, and Auth for admin role checks | Free: 500MB DB, 2 projects, 500K Edge Function invocations/mo | Pro: $25/mo (8GB DB, unlimited Edge Function invocations up to 500K/mo) |
| Upstash Redis | Rate limiting on the /track endpoint — a simple per-user counter prevents a misbehaving client from flooding the events table | Free tier: 10K commands/day | $0.2/100K commands pay-as-you-go (approx) |
| Papa Parse (npm) | Client-side or Server Action CSV generation from events query results; handles column headers and escaping automatically | Free, open-source (MIT license) | Free |
| Recharts (npm) | Line charts for DAU trend, bar charts for event frequency, FunnelChart for conversion funnel, and cohort heatmap coloring | Free, open-source (MIT license) | Free |
| PostHog (optional self-hosted alternative) | Open-source product analytics as a drop-in replacement for the materialized view layer — event-level querying, funnel analysis, cohort builder, and session recordings | Self-hosted: free indefinitely. Cloud: 1M events/mo free | Cloud: $0.00031/event above 1M (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 covers the events table, Edge Function ingestion, and Realtime at this volume. Upstash free tier handles rate limiting. Recharts and Papa Parse are free npm packages. Total: $0/mo.
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.
Admin dashboard exposes all user events to any authenticated user
Symptom: Both Lovable and V0 commonly generate Supabase event queries using supabase.from('events').select('*') with the anon key and no user filter. Because the default Supabase RLS for a new table is disabled, or because the generated policy only checks auth.role() = 'authenticated', any logged-in user can read every other user's click history, session data, and behavior. This is a silent data breach — users have no indication it's happening.
Fix: Add the RLS policy from this page's data model: USING (auth.jwt() ->> 'role' = 'admin') on the events SELECT policy. Set the admin role in Supabase Auth by adding role: 'admin' to the user's app_metadata via the Supabase Dashboard or a privileged Edge Function. On the client, use the service_role key only in Server Components or Edge Functions — never in browser-facing code.
Materialized view returns yesterday's data because pg_cron hasn't run yet
Symptom: The retention cohort table and DAU chart pull from materialized views refreshed by pg_cron every hour. When an admin first opens the dashboard in the morning, the views show data up to the last refresh point — potentially hours old. Without any indication of staleness, the admin concludes the feature is broken and files a bug report.
Fix: Query the mv_refresh_log table alongside the materialized view and show 'Last updated X minutes ago' next to every chart that draws from a materialized view. Add a 'Refresh now' button that calls the refresh_analytics_views() Supabase RPC function — this triggers a CONCURRENTLY refresh so reads are not blocked. The RPC function is included in the data model SQL above.
Duplicate session_start events inflate cohort numbers in development
Symptom: React's Strict Mode in development intentionally mounts components twice, causing useEffect to fire twice and sending two session_start events per page load. In production (where Strict Mode effects fire once) the numbers are correct, but the development-mode inflated events end up in the same events table if you're pointing at a production or staging Supabase project during development.
Fix: Generate a session_id UUID once per session using sessionStorage (not localStorage — localStorage persists across sessions): if (!sessionStorage.getItem('session_id')) { sessionStorage.setItem('session_id', crypto.randomUUID()); }. Add a UNIQUE(session_id, event_name) constraint for session-boundary events (session_start, session_end) so the database rejects duplicates. Use a separate Supabase project for local development to prevent test events from polluting production analytics.
Real-time active user counter freezes when the browser tab goes to the background
Symptom: Supabase Realtime WebSocket subscriptions are throttled or suspended by the browser after the tab becomes inactive for about 60 seconds. When the admin returns to the dashboard, the counter shows a stale number from before the inactivity period — new events that arrived while the tab was in the background are not reflected. The counter can be off by hundreds of events in a busy app.
Fix: Listen to the document.visibilitychange event: when visibility changes to 'visible', re-subscribe to the Realtime channel and re-fetch the last-5-minutes count via a REST fallback query (SELECT COUNT(DISTINCT user_id) FROM events WHERE created_at > now() - interval '5 minutes'). This ensures the counter is always accurate within 1 request round-trip of the admin returning to the tab.
localStorage access crashes SSR in V0/Next.js generated code
Symptom: V0 sometimes generates code that reads localStorage (for persisting the selected date range) during the initial server render in a Next.js Server Component or during SSR of a Client Component. This throws 'ReferenceError: localStorage is not defined' because localStorage does not exist in the Node.js environment. The error only surfaces on first page load; client-side navigation works fine, which makes it hard to reproduce locally if you're not testing a cold SSR load.
Fix: Wrap all localStorage access in a typeof window !== 'undefined' check before reading: const savedRange = typeof window !== 'undefined' ? localStorage.getItem('analytics_range') : null. For a cleaner solution, use cookies via Next.js next/headers to persist user preferences — cookies are available server-side and do not cause SSR errors.
Best practices
Name events in past tense with a noun-verb pattern — 'user_signed_up', 'feature_opened', 'export_clicked' — and document the schema in a shared events catalog before writing a line of code; renaming events mid-product breaks all historical funnel analysis
Always use the service_role key server-side for dashboard queries and the anon key with RLS for client-side event ingestion — never use the service_role key in browser-facing code
Add a last_refreshed_at display to every chart backed by a materialized view; admins who see stale data without timestamps will assume the feature is broken
Tag every event with platform ('web', 'ios', 'android') and app_version in the properties JSONB from day one — retrofitting these dimensions into existing event data requires a full table scan
Generate session_id once per browser session using sessionStorage and include it in every event payload — without session_id, you cannot calculate session duration, page depth, or funnel steps within a session
Rate-limit the /track endpoint from the first deploy using Upstash Redis; a single automated test script that fires events in a tight loop can fill your Supabase free-tier DB in hours
Implement GDPR right-to-erasure from the start: a single DELETE FROM events WHERE user_id = $id cascades through the foreign key, but you also need to purge the user from any materialized views — schedule a post-deletion view refresh
Build the CSV export with streaming for date ranges that produce more than 10K rows — loading 100K events into memory before streaming kills the server function and produces a timeout
When You Need Custom Development
V0 or Lovable handles the standard admin dashboard for apps with up to low millions of events. These signals indicate you've outgrown the AI scaffold:
- Event volume exceeds 1M/day — Postgres materialized views require minutes to refresh and block dashboard reads; the correct solution is migrating the events table to ClickHouse or BigQuery for OLAP queries, which is a non-trivial infrastructure change
- GDPR or CCPA compliance requires a verified right-to-erasure pipeline: every deletion must cascade across the events table, materialized views, any cold storage archives, and third-party analytics tools — this requires a custom deletion pipeline with audit logging
- Multi-tenant SaaS: analytics must be isolated per customer organization with zero cross-tenant data leakage — complex RLS and schema-per-tenant design that goes far beyond the single-tenant pattern here
- Behavioral ML requirements: churn prediction, anomaly detection, or cohort clustering from event sequences requires a data warehouse plus a Python ML pipeline, not a Supabase materialized view
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
How do I track events without slowing down my app?
Send events to the Supabase Edge Function asynchronously — fire and forget from the client without awaiting the response. On the client side, use navigator.sendBeacon() for session_end events (which fire when the user closes the tab) so they are not dropped. In the Edge Function, batch inserts using a single INSERT ... VALUES (row1), (row2), ... statement when multiple events arrive in quick succession. This keeps the tracking call off the critical rendering path.
Can I see a specific user's full activity history?
Yes — the user lookup section of the dashboard queries SELECT * FROM events WHERE user_id = (SELECT id FROM auth.users WHERE email = $email) ORDER BY created_at DESC LIMIT 50. The RLS policy ensures only admins can run this query. In Supabase Dashboard you can also use the Table Editor with a user_id filter for ad-hoc lookups during debugging, since the Dashboard uses the service_role key and bypasses RLS.
How do I build a funnel showing where users drop off during onboarding?
Define the onboarding funnel as an ordered array of event names — for example ['user_signed_up', 'profile_completed', 'first_feature_used', 'invite_sent']. Call the calculate_funnel(event_array, start_date, end_date) RPC function from the data model above, which returns step counts and conversion rates. Render the result as a Recharts FunnelChart. Name your events consistently from the start — you cannot retroactively reconstruct a funnel from events that were named differently in early builds.
What is the difference between a daily active user and a session?
A daily active user (DAU) is a count of distinct user_ids with at least one event on a given calendar day — a user counts once no matter how many times they open the app. A session is a single continuous usage period with a start and end timestamp stored in user_sessions; one user can have multiple sessions in a day. DAU tells you reach; session data tells you depth of engagement. Both are useful and complementary.
How do I export all event data to a spreadsheet?
Click the Export CSV button on the dashboard. It triggers a Server Action that queries the events table for the selected date range using the service_role key, passes the result through Papa Parse's unparse() function to generate CSV format, and returns the file as a download response with Content-Disposition: attachment. For date ranges that produce more than 10K rows, the Server Action streams the response in chunks — do not try to load the full result set into memory first.
Can I track events from a mobile app and web app in the same dashboard?
Yes — include a platform field in every event's properties JSONB (set to 'web', 'ios', or 'android' depending on the client). The events table schema accepts all sources through the same /track Edge Function endpoint. On the dashboard, add a platform filter chip to segment charts by source. The retention cohort view automatically includes all platforms unless you add a WHERE properties->>'platform' = 'web' clause.
How do I make sure admin-only data isn't exposed to regular users?
Two layers are required and both must be in place. First, RLS on the events table: add a SELECT policy with USING (auth.jwt() ->> 'role' = 'admin') — this blocks any client-side query from a non-admin user at the database level, regardless of what code they run. Second, route protection: in Next.js, check the admin role in the Server Component and redirect to / if the user is not an admin. Set the admin role by adding role: 'admin' to the user's app_metadata in the Supabase Auth Dashboard. Never rely on client-side redirects alone.
Do I need PostHog or Mixpanel, or can I build this myself in Supabase?
For most apps under 1M events per day, building in Supabase is the right choice: zero per-event fees, full data ownership, and the dashboard you build is exactly what your team needs. PostHog or Mixpanel become worth considering when you need features like session recordings, heatmaps, or feature flags, or when your event volume makes Postgres OLAP queries too slow. PostHog self-hosted is a solid middle ground — open-source, free to run, and purpose-built for behavioral analytics while still connecting to your Supabase user table.
Need this feature production-ready?
RapidDev builds user activity analytics into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.