Build a product analytics platform in Lovable that tracks user events and sessions with a JavaScript snippet delivered via Edge Function, uses Supabase materialized views for fast aggregations, and renders trend, funnel, and retention charts with Recharts — all without a third-party analytics vendor.
What you're building
A product analytics platform built in Lovable captures behavioral data from your application without sending it to a third-party service. The tracking architecture has two parts: a Supabase Edge Function that serves a lightweight JavaScript snippet and a separate ingest endpoint that receives events. Your tracked application loads the snippet, which provides a track() function call. Every event — page view, button click, feature usage — is sent as a POST request to the ingest endpoint.
The data lands in two Supabase tables: sessions (one row per browser session with start/end times, device, and referrer) and events (one row per tracked action with session_id, event_name, and a JSONB properties field). These tables can grow to millions of rows, so materialized views handle all the aggregation work. Postgres refreshes them every hour via pg_cron, and the dashboard reads from the views rather than the raw tables.
Funnel analysis works by checking what percentage of users who completed step N also completed step N+1 within a 30-day window. Retention is computed by comparing how many users from a signup cohort returned in each subsequent week. Both calculations happen in Postgres functions so the React components stay simple.
Final result
A self-hosted product analytics dashboard with event tracking, trend charts, funnel analysis, and cohort retention — deployed to your own Supabase project.
Tech stack
Prerequisites
- Lovable Pro account (Edge Functions are required)
- Supabase project with URL, anon key, and service role key ready
- VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in Cloud tab → Secrets
- SUPABASE_SERVICE_ROLE_KEY in Supabase Secrets for the Edge Function
- Access to modify the HTML of the application you want to track (to add the script tag)
Build steps
Scaffold the events and sessions schema
Create the tracking tables with appropriate partitioning strategy, indexes for time-range queries, and the initial materialized views. The schema is optimized for append-only writes and range queries.
1Create a product analytics app with Supabase. Set up these tables:23- projects: id, owner_id, name, tracking_key (uuid, unique, auto-generated), created_at45- sessions: id, project_id, anonymous_id (text, browser fingerprint or random UUID), user_id (uuid nullable, set after login), started_at (timestamptz), ended_at (timestamptz nullable), duration_seconds (integer nullable), page_count (integer default 1), device_type ('desktop'|'tablet'|'mobile'), browser (text), referrer (text nullable), landing_page (text), created_at67- events: id, project_id, session_id, anonymous_id (text), user_id (uuid nullable), event_name (text), page_url (text), properties (jsonb default '{}'), client_timestamp (timestamptz), created_at89Create indexes:10- events(project_id, event_name, created_at DESC)11- events(project_id, user_id, created_at DESC) WHERE user_id IS NOT NULL12- sessions(project_id, started_at DESC)1314Create a materialized view daily_active_users:15SELECT project_id, DATE(created_at) as day, COUNT(DISTINCT anonymous_id) as dau, COUNT(*) as event_count FROM events GROUP BY project_id, DATE(created_at)1617Create a materialized view top_events:18SELECT project_id, event_name, COUNT(*) as event_count, COUNT(DISTINCT anonymous_id) as unique_users FROM events WHERE created_at > now() - interval '30 days' GROUP BY project_id, event_name ORDER BY event_count DESC1920Enable RLS: project owners can only read data for their own projects.21Seed with 1000 sample events across 100 sessions for testing.Pro tip: For production scale, consider partitioning the events table by month using Postgres range partitioning. Ask Lovable to add this after the initial build is working — it is easier to add once you understand the query patterns.
Expected result: All tables and indexes are created. Both materialized views are populated with the seed data. Querying SELECT * FROM daily_active_users returns rows grouped by day.
Build the event ingest Edge Function and tracking snippet
Create a Supabase Edge Function that accepts event payloads from tracked applications. A second endpoint serves the JavaScript snippet that tracked apps include via a script tag.
1Create two Supabase Edge Functions:231. supabase/functions/track/index.ts — event ingestion:4- Accept POST with JSON body: { tracking_key, session_id, anonymous_id, user_id, event_name, page_url, properties, client_timestamp }5- Validate tracking_key exists in projects table (use service role client)6- Insert into events table7- If session_id does not exist in sessions table, create a new session row8- Return { ok: true } with 201 status9- Accept CORS from any origin (tracked apps may be on any domain)10- No authentication required — tracking is public-facing11122. supabase/functions/snippet/index.ts — JavaScript snippet delivery:13- Accept GET requests with ?key=[tracking_key] query parameter14- Return Content-Type: application/javascript15- Return a minified JavaScript snippet that:16 a. Generates or retrieves a UUID from localStorage as anonymous_id17 b. Creates a session_id (UUID) stored in sessionStorage18 c. Exposes window.analytics.track(eventName, properties) function19 d. The track() function POSTs to the ingest function URL with all required fields20 e. Auto-tracks page views on load and on popstate/hashchange21 f. Includes the tracking_key from the snippet URLPro tip: Keep the JavaScript snippet under 2KB minified. Any bloat in the snippet loads on every page of the tracked application. Avoid importing any libraries in the snippet — use only vanilla browser APIs.
Expected result: Adding a script tag to any webpage fires a page view event to the ingest function. You can verify events are arriving by watching the events table in the Supabase Table Editor.
Build the trend chart for DAU and event volume
Create the main trend chart reading from the daily_active_users materialized view. Show a dual-axis chart with DAU as a line and event volume as bars for the selected date range.
1import { useQuery } from '@tanstack/react-query'2import { supabase } from '@/integrations/supabase/client'3import { ComposedChart, Line, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts'4import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'5import { format, subDays } from 'date-fns'67type DauRow = { day: string; dau: number; event_count: number }89type Props = { projectId: string; days?: number }1011export function TrendChart({ projectId, days = 30 }: Props) {12 const since = format(subDays(new Date(), days), 'yyyy-MM-dd')1314 const { data = [], isLoading } = useQuery<DauRow[]>({15 queryKey: ['dau', projectId, days],16 queryFn: async () => {17 const { data, error } = await supabase18 .from('daily_active_users')19 .select('day, dau, event_count')20 .eq('project_id', projectId)21 .gte('day', since)22 .order('day', { ascending: true })23 if (error) throw error24 return data.map((r) => ({ ...r, day: format(new Date(r.day), 'MMM d') }))25 },26 staleTime: 10 * 60_000,27 })2829 if (isLoading) return <div className="h-64 animate-pulse rounded-lg bg-muted" />3031 return (32 <Card>33 <CardHeader><CardTitle>Active Users & Events</CardTitle></CardHeader>34 <CardContent>35 <ResponsiveContainer width="100%" height={280}>36 <ComposedChart data={data}>37 <XAxis dataKey="day" tick={{ fontSize: 11 }} />38 <YAxis yAxisId="left" />39 <YAxis yAxisId="right" orientation="right" />40 <Tooltip />41 <Legend />42 <Bar yAxisId="right" dataKey="event_count" name="Events" fill="#e0e7ff" />43 <Line yAxisId="left" dataKey="dau" name="DAU" stroke="#6366f1" strokeWidth={2} dot={false} />44 </ComposedChart>45 </ResponsiveContainer>46 </CardContent>47 </Card>48 )49}Pro tip: Use a ComposedChart with two YAxis (one for DAU, one for event volume) so the two metrics do not compete for the same scale — daily active users might be in the hundreds while event count is in the thousands.
Expected result: The trend chart shows 30 days of DAU as a purple line overlaid on event count bars. The dual Y-axis prevents either metric from being compressed by the other's scale.
Build the funnel analysis chart
Create a configurable funnel where users define up to 5 event steps. A Postgres function computes how many users completed each step, and Recharts FunnelChart renders the conversion rates.
1Build a FunnelAnalysis component at src/components/analytics/FunnelAnalysis.tsx.23Requirements:4- Create a Postgres function get_funnel_stats(p_project_id uuid, p_steps text[], p_days integer) that:5 - Takes an ordered array of event_name steps6 - For each step, counts distinct anonymous_ids who fired that event within p_days days7 - A user must have completed all prior steps to count in the current step (ordered funnel)8 - Returns rows: { step_name, step_index, user_count, conversion_rate }9- Build the UI:10 - A shadcn/ui Card with a 'Configure Funnel' section at the top11 - Up to 5 Input fields for step event names (with + and - buttons to add/remove steps)12 - A 30d/60d/90d period Select13 - A Calculate Button that calls the Postgres function via supabase.rpc()14 - Below the config, show Recharts FunnelChart with step names and conversion rates as LabelList15 - Each funnel layer uses a different shade of indigo16 - Below the chart, show a summary table: step, users, conv. from previous, conv. from topPro tip: Pre-populate the funnel steps with your most common conversion flow (e.g. page_view → signup → first_event → upgraded) so new users immediately see a meaningful chart rather than an empty configuration.
Expected result: Entering event names in the funnel steps and clicking Calculate renders a funnel chart. The summary table shows the conversion rate at each step and the overall end-to-end conversion rate.
Build the cohort retention table
Create a retention table that groups users by their signup week and shows what percentage returned in each subsequent week up to 8 weeks out.
1Build a RetentionTable component at src/components/analytics/RetentionTable.tsx.23Requirements:4- Create a Postgres function get_retention(p_project_id uuid, p_weeks integer default 8) that:5 - Groups users by the week they first appeared (week 0 = signup cohort)6 - For each cohort week, counts users who fired any event in week 1, 2, 3... up to p_weeks7 - Returns rows: { cohort_week (date), cohort_size, week_1_pct, week_2_pct, ..., week_8_pct }8- Build the UI:9 - Render an HTML table using shadcn/ui Table, TableHeader, TableBody, TableRow, TableCell10 - Header row: Cohort Week, Users, Week 1, Week 2, ... Week 811 - Each data cell shows the percentage and is colored with a background from white (0%) to indigo (100%) using inline style: background: rgba(99,102,241, {pct/100})12 - The cohort_week column shows the week start date formatted as 'MMM d, yyyy'13 - Show a Skeleton while loading14 - Add a tooltip on each percentage cell showing the absolute user countExpected result: A color-coded retention table appears showing each signup cohort as a row. Darker indigo cells indicate higher retention. Hovering shows the absolute user count.
Complete code
1import { useQuery } from '@tanstack/react-query'2import { supabase } from '@/integrations/supabase/client'3import { ComposedChart, Line, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts'4import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'5import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'6import { Skeleton } from '@/components/ui/skeleton'7import { useState } from 'react'8import { format, subDays } from 'date-fns'910type DauRow = { day: string; dau: number; event_count: number }1112export function TrendChart({ projectId }: { projectId: string }) {13 const [days, setDays] = useState(30)1415 const { data = [], isLoading } = useQuery<DauRow[]>({16 queryKey: ['dau', projectId, days],17 queryFn: async () => {18 const since = format(subDays(new Date(), days), 'yyyy-MM-dd')19 const { data, error } = await supabase20 .from('daily_active_users')21 .select('day, dau, event_count')22 .eq('project_id', projectId)23 .gte('day', since)24 .order('day', { ascending: true })25 if (error) throw error26 return (data ?? []).map((r) => ({ ...r, day: format(new Date(r.day), 'MMM d') }))27 },28 staleTime: 10 * 60_000,29 })3031 return (32 <Card>33 <CardHeader className="flex flex-row items-center justify-between">34 <CardTitle className="text-base">Active Users & Events</CardTitle>35 <Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>36 <SelectTrigger className="w-24"><SelectValue /></SelectTrigger>37 <SelectContent>38 <SelectItem value="7">Last 7d</SelectItem>39 <SelectItem value="30">Last 30d</SelectItem>40 <SelectItem value="90">Last 90d</SelectItem>41 </SelectContent>42 </Select>43 </CardHeader>44 <CardContent>45 {isLoading ? <Skeleton className="h-[280px] w-full" /> : (46 <ResponsiveContainer width="100%" height={280}>47 <ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>48 <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />49 <XAxis dataKey="day" tick={{ fontSize: 11 }} />50 <YAxis yAxisId="left" tick={{ fontSize: 11 }} />51 <YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />52 <Tooltip />53 <Legend />54 <Bar yAxisId="right" dataKey="event_count" name="Events" fill="#e0e7ff" radius={[2,2,0,0]} />55 <Line yAxisId="left" dataKey="dau" name="DAU" stroke="#6366f1" strokeWidth={2} dot={false} />56 </ComposedChart>57 </ResponsiveContainer>58 )}59 </CardContent>60 </Card>61 )62}Customization ideas
Custom event properties explorer
Add a properties explorer that lets users select an event name and then see a breakdown of the most common values for each property key in that event's JSONB. This helps identify which features or pages drive engagement.
User journey paths
Build a Sankey chart showing the most common event sequences users take in a session. Aggregate the first 5 events per session, sort by frequency, and render the top 20 paths as flow arrows.
Real-time activity monitor
Add a 'Live' tab with a Supabase Realtime subscription on the events table showing page views and events from the last 60 seconds, with a rolling active users count and a map of page URLs being visited.
A/B test analysis
Add a test_variant property to events. Build an A/B test results view that compares conversion rates between variants using the funnel analysis function, filtered by the test_variant property value.
Goal tracking
Create an analytics_goals table where project owners define goal events (e.g. 'signed_up', 'upgraded'). The dashboard shows a goal completion rate card for each defined goal alongside the trend chart.
Segment comparison
Add user segment support by tagging events with a segment property (new user, power user, churned). Build a comparison chart that shows the trend and funnel side by side for two selected segments.
Common pitfalls
Pitfall: Querying raw events tables directly from the dashboard for aggregations
How to avoid: Always read aggregated data from materialized views or Postgres functions. Query raw events only for debugging individual sessions.
Pitfall: Putting the SUPABASE_SERVICE_ROLE_KEY in the tracking snippet JavaScript
How to avoid: The ingest Edge Function uses the service role key server-side via Deno.env. The snippet only needs the public tracking_key — never a Supabase key.
Pitfall: Refreshing materialized views on every dashboard load
How to avoid: Refresh views on a schedule using Supabase pg_cron (every hour or every 15 minutes) rather than on demand from the dashboard.
Pitfall: Not handling anonymous users before login
How to avoid: Always generate and store an anonymous_id in localStorage on first visit. Link the anonymous_id to a user_id after login using an identity merge approach.
Best practices
- Use materialized views for all aggregations — never query the raw events table from the analytics dashboard.
- Schedule materialized view refreshes with pg_cron rather than triggering them from the client.
- Keep the tracking JavaScript snippet under 2KB — it loads on every page of the tracked application and should never affect page performance.
- Store event properties as JSONB to allow flexible event schemas — different event types can have different property structures without schema migrations.
- Add indexes on (project_id, event_name, created_at) and (project_id, anonymous_id, created_at) to support the most common analytics queries.
- Always track both anonymous_id and user_id — anonymous tracking captures the full funnel including pre-signup behavior.
- Enable RLS on events and sessions scoped by project_id so each project owner only sees their own tracking data.
- Use CONCURRENTLY when refreshing large materialized views to avoid blocking read queries during refresh.
AI prompts to try
Copy these prompts to build this project faster.
I have a Supabase events table with columns: id, project_id, anonymous_id, user_id, event_name, created_at. Help me write a Postgres function get_funnel_stats(p_project_id uuid, p_steps text[], p_days integer) that computes an ordered conversion funnel — each step counts only users who also completed all prior steps within the time window. Return rows with step_name, step_index, user_count, and conversion_rate from the previous step.
Add a project settings page to the analytics dashboard. It should show the project's tracking key (with a copy button), the JavaScript snippet installation instructions as a formatted code block showing exactly which script tag to add, a test connection section that shows the last 5 received events in a table, and a data retention setting (30/90/180/365 days) that deletes old events via a scheduled Edge Function.
In Lovable, build a sessions explorer for the analytics dashboard. Create a sessions DataTable showing: session ID (truncated), anonymous_id (truncated), user_id (if known), duration (formatted as '2m 34s'), page count, device type (with icon), landing page (truncated URL), referrer, and start time. Clicking a row opens a Sheet with the full session timeline: each event in chronological order with event_name, page_url, properties as collapsible JSON, and timestamp. Add a filter for date range, device type, and minimum/maximum session duration.
Frequently asked questions
How do I add the tracking snippet to my application?
Deploy the snippet Edge Function to Supabase, then add a script tag to your HTML: a script tag pointing to your Supabase snippet Edge Function URL with your tracking key. After that, call window.analytics.track('button_clicked', { label: 'Sign Up' }) anywhere in your JavaScript.
Does this track users across different browsers or devices?
The tracking snippet uses localStorage for the anonymous_id, which is device and browser specific. Users are treated as separate anonymous identities on different devices unless they log in — at which point the user_id links their events across sessions.
How often do the materialized views refresh?
By default, materialized views do not refresh automatically. Set up a pg_cron job in Supabase to refresh them on a schedule. Go to Database → Extensions → Enable pg_cron, then add a job: SELECT cron.schedule('refresh-analytics', '0 * * * *', 'REFRESH MATERIALIZED VIEW CONCURRENTLY daily_active_users').
Can I track events from a mobile app as well?
Yes. The ingest Edge Function accepts standard HTTP POST requests from any client. Build a thin wrapper in your mobile app (React Native, Swift, etc.) that sends the same JSON payload structure. The anonymous_id should be the device ID or a UUID stored in device storage.
How do I handle GDPR and user data deletion requests?
Add a DELETE endpoint to your Edge Function that accepts a user_id or anonymous_id and removes all associated events and sessions rows. Log the deletion request in an audit table. For anonymization instead of deletion, update user_id to NULL and replace anonymous_id with a hash.
The funnel is showing 100% at every step. Why?
The funnel function checks if a user fired each event within the time window, but does not enforce ordering by default. If all your seed data users fired all events (because they were seeded together), every step shows 100%. Add ordering logic to the function: a user must have fired step N at any time before step N+1.
Can I track server-side events as well as browser events?
Yes. Call the ingest Edge Function from your server with the same JSON payload. Include the user_id (which you know server-side) and generate a session_id that represents the server-side operation. Mark these events with a source: 'server' property so you can distinguish them in queries.
Can RapidDev help me customize the tracking for my specific application?
Yes. RapidDev can help you design an event taxonomy, set up the materialized view refresh schedule, and build custom funnel and retention analyses tailored to your product's conversion flows.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation