Skip to main content
RapidDev - Software Development Agency

How to Build a Product Analytics with Lovable

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'll build

  • events and sessions Supabase tables with anonymous-safe user tracking
  • JavaScript tracking snippet served by a Supabase Edge Function for browser-side event collection
  • Materialized views refreshed every hour that power fast dashboard queries
  • Trend chart showing daily active users and event volume over time
  • Funnel analysis chart with configurable steps and per-step drop-off rates
  • Cohort retention table showing user retention percentages by signup week
  • Top events table and session duration breakdown in a DataTable
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend Dashboard
SupabaseDatabase, Materialized Views & Edge Functions
Supabase Edge FunctionsEvent Ingestion & Snippet Delivery
RechartsTrend, Funnel & Retention Charts
shadcn/uiUI Components
TanStack Table v8Events & Sessions DataTable

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

1

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.

prompt.txt
1Create a product analytics app with Supabase. Set up these tables:
2
3- projects: id, owner_id, name, tracking_key (uuid, unique, auto-generated), created_at
4
5- 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_at
6
7- 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_at
8
9Create indexes:
10- events(project_id, event_name, created_at DESC)
11- events(project_id, user_id, created_at DESC) WHERE user_id IS NOT NULL
12- sessions(project_id, started_at DESC)
13
14Create 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)
16
17Create 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 DESC
19
20Enable 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.

2

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.

prompt.txt
1Create two Supabase Edge Functions:
2
31. 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 table
7- If session_id does not exist in sessions table, create a new session row
8- Return { ok: true } with 201 status
9- Accept CORS from any origin (tracked apps may be on any domain)
10- No authentication required tracking is public-facing
11
122. supabase/functions/snippet/index.ts JavaScript snippet delivery:
13- Accept GET requests with ?key=[tracking_key] query parameter
14- Return Content-Type: application/javascript
15- Return a minified JavaScript snippet that:
16 a. Generates or retrieves a UUID from localStorage as anonymous_id
17 b. Creates a session_id (UUID) stored in sessionStorage
18 c. Exposes window.analytics.track(eventName, properties) function
19 d. The track() function POSTs to the ingest function URL with all required fields
20 e. Auto-tracks page views on load and on popstate/hashchange
21 f. Includes the tracking_key from the snippet URL

Pro 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.

3

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.

src/components/analytics/TrendChart.tsx
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'
6
7type DauRow = { day: string; dau: number; event_count: number }
8
9type Props = { projectId: string; days?: number }
10
11export function TrendChart({ projectId, days = 30 }: Props) {
12 const since = format(subDays(new Date(), days), 'yyyy-MM-dd')
13
14 const { data = [], isLoading } = useQuery<DauRow[]>({
15 queryKey: ['dau', projectId, days],
16 queryFn: async () => {
17 const { data, error } = await supabase
18 .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 error
24 return data.map((r) => ({ ...r, day: format(new Date(r.day), 'MMM d') }))
25 },
26 staleTime: 10 * 60_000,
27 })
28
29 if (isLoading) return <div className="h-64 animate-pulse rounded-lg bg-muted" />
30
31 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.

4

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.

prompt.txt
1Build a FunnelAnalysis component at src/components/analytics/FunnelAnalysis.tsx.
2
3Requirements:
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 steps
6 - For each step, counts distinct anonymous_ids who fired that event within p_days days
7 - 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 top
11 - Up to 5 Input fields for step event names (with + and - buttons to add/remove steps)
12 - A 30d/60d/90d period Select
13 - 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 LabelList
15 - Each funnel layer uses a different shade of indigo
16 - Below the chart, show a summary table: step, users, conv. from previous, conv. from top

Pro 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.

5

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.

prompt.txt
1Build a RetentionTable component at src/components/analytics/RetentionTable.tsx.
2
3Requirements:
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_weeks
7 - 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, TableCell
10 - Header row: Cohort Week, Users, Week 1, Week 2, ... Week 8
11 - 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 loading
14 - Add a tooltip on each percentage cell showing the absolute user count

Expected 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

src/components/analytics/TrendChart.tsx
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'
9
10type DauRow = { day: string; dau: number; event_count: number }
11
12export function TrendChart({ projectId }: { projectId: string }) {
13 const [days, setDays] = useState(30)
14
15 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 supabase
20 .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 error
26 return (data ?? []).map((r) => ({ ...r, day: format(new Date(r.day), 'MMM d') }))
27 },
28 staleTime: 10 * 60_000,
29 })
30
31 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.