Supabase Analytics: Track User Behavior and Metrics

Implementing analytics enables tracking user behavior, monitoring application performance, measuring feature adoption, understanding user journeys, and making data-driven decisions through event tracking, metrics aggregation, and visualization dashboards built directly with Supabase database and real-time capabilities. Unlike third-party analytics requiring external dependencies and data sharing, self-hosted analytics maintain full data ownership, enable custom metrics, provide real-time insights, integrate seamlessly with application data, and comply with privacy regulations. This comprehensive guide covers designing analytics schema for events and metrics, implementing event tracking with client SDKs, building real-time dashboards with live data subscriptions, calculating key performance indicators and user engagement metrics, implementing funnel analysis and cohort tracking, creating materialized views for performance, visualizing data with charts and graphs, and integrating with third-party analytics platforms. Analytics become essential when understanding user behavior, optimizing conversion funnels, measuring feature success, tracking application health, or building product-led growth strategies. Before proceeding, understand database design, real-time subscriptions, and database functions.
Analytics Schema Design
-- Events table for tracking all user actions
create table analytics_events (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade,
session_id uuid,
event_name text not null,
event_category text,
properties jsonb default '{}'::jsonb,
device_info jsonb,
location jsonb,
created_at timestamp with time zone default now()
);
-- Add indexes for common queries
create index idx_events_user_id on analytics_events(user_id);
create index idx_events_event_name on analytics_events(event_name);
create index idx_events_created_at on analytics_events(created_at desc);
create index idx_events_session_id on analytics_events(session_id);
create index idx_events_properties on analytics_events using gin(properties);
-- Page views tracking
create table page_views (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade,
session_id uuid not null,
page_url text not null,
page_title text,
referrer text,
duration_seconds int,
created_at timestamp with time zone default now()
);
create index idx_page_views_user_id on page_views(user_id);
create index idx_page_views_session_id on page_views(session_id);
create index idx_page_views_created_at on page_views(created_at desc);
-- User sessions
create table user_sessions (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade,
started_at timestamp with time zone default now(),
ended_at timestamp with time zone,
device_type text,
browser text,
os text,
ip_address inet,
country text,
city text
);
-- Daily metrics aggregation
create table daily_metrics (
id uuid default gen_random_uuid() primary key,
date date not null unique,
total_users int default 0,
active_users int default 0,
new_users int default 0,
total_sessions int default 0,
total_page_views int default 0,
avg_session_duration_seconds int default 0,
bounce_rate decimal(5,2) default 0,
calculated_at timestamp with time zone default now()
);
-- User engagement scores
create table user_engagement (
user_id uuid primary key references auth.users(id) on delete cascade,
last_active_at timestamp with time zone,
total_sessions int default 0,
total_events int default 0,
days_active int default 0,
engagement_score decimal(10,2) default 0,
updated_at timestamp with time zone default now()
);
-- Feature usage tracking
create table feature_usage (
id uuid default gen_random_uuid() primary key,
feature_name text not null,
user_id uuid references auth.users(id) on delete cascade,
usage_count int default 1,
first_used_at timestamp with time zone default now(),
last_used_at timestamp with time zone default now(),
unique(feature_name, user_id)
);
create index idx_feature_usage_feature on feature_usage(feature_name);Implementing Event Tracking
// lib/analytics.ts
import { supabase } from './supabase'
interface TrackEventParams {
eventName: string
category?: string
properties?: Record<string, any>
}
class Analytics {
private sessionId: string
private deviceInfo: any
constructor() {
this.sessionId = this.getOrCreateSessionId()
this.deviceInfo = this.getDeviceInfo()
}
private getOrCreateSessionId(): string {
let sessionId = sessionStorage.getItem('analytics_session_id')
if (!sessionId) {
sessionId = crypto.randomUUID()
sessionStorage.setItem('analytics_session_id', sessionId)
}
return sessionId
}
private getDeviceInfo() {
return {
userAgent: navigator.userAgent,
language: navigator.language,
screenWidth: window.screen.width,
screenHeight: window.screen.height,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
async trackEvent({ eventName, category, properties = {} }: TrackEventParams) {
const { data: { user } } = await supabase.auth.getUser()
await supabase.from('analytics_events').insert({
user_id: user?.id,
session_id: this.sessionId,
event_name: eventName,
event_category: category,
properties,
device_info: this.deviceInfo,
})
}
async trackPageView(pageUrl: string, pageTitle?: string) {
const { data: { user } } = await supabase.auth.getUser()
await supabase.from('page_views').insert({
user_id: user?.id,
session_id: this.sessionId,
page_url: pageUrl,
page_title: pageTitle || document.title,
referrer: document.referrer,
})
}
async updatePageViewDuration(pageViewId: string, duration: number) {
await supabase
.from('page_views')
.update({ duration_seconds: Math.floor(duration) })
.eq('id', pageViewId)
}
async trackFeatureUsage(featureName: string) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
await supabase.rpc('increment_feature_usage', {
p_feature_name: featureName,
p_user_id: user.id,
})
}
async startSession() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data } = await supabase.from('user_sessions').insert({
id: this.sessionId,
user_id: user.id,
device_type: this.getDeviceType(),
browser: this.getBrowser(),
os: this.getOS(),
}).select().single()
return data
}
async endSession() {
await supabase
.from('user_sessions')
.update({ ended_at: new Date().toISOString() })
.eq('id', this.sessionId)
}
private getDeviceType(): string {
const ua = navigator.userAgent
if (/mobile/i.test(ua)) return 'mobile'
if (/tablet/i.test(ua)) return 'tablet'
return 'desktop'
}
private getBrowser(): string {
const ua = navigator.userAgent
if (ua.includes('Chrome')) return 'Chrome'
if (ua.includes('Firefox')) return 'Firefox'
if (ua.includes('Safari')) return 'Safari'
if (ua.includes('Edge')) return 'Edge'
return 'Other'
}
private getOS(): string {
const ua = navigator.userAgent
if (ua.includes('Windows')) return 'Windows'
if (ua.includes('Mac')) return 'macOS'
if (ua.includes('Linux')) return 'Linux'
if (ua.includes('Android')) return 'Android'
if (ua.includes('iOS')) return 'iOS'
return 'Other'
}
}
export const analytics = new Analytics()
// Usage in React app
import { useEffect } from 'react'
import { analytics } from './lib/analytics'
function App() {
useEffect(() => {
// Start session on app load
analytics.startSession()
// Track page views
analytics.trackPageView(window.location.pathname)
// End session on unload
const handleUnload = () => {
analytics.endSession()
}
window.addEventListener('beforeunload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleUnload)
}
}, [])
return <div>Your App</div>
}
// Track specific events
function ProductPage({ productId }: { productId: string }) {
useEffect(() => {
analytics.trackEvent({
eventName: 'product_viewed',
category: 'ecommerce',
properties: { productId },
})
}, [productId])
const handleAddToCart = () => {
analytics.trackEvent({
eventName: 'add_to_cart',
category: 'ecommerce',
properties: { productId, price: 29.99 },
})
}
return <button onClick={handleAddToCart}>Add to Cart</button>
}Calculating Key Metrics
-- Function to calculate daily metrics
create or replace function calculate_daily_metrics(target_date date)
returns void as $$
begin
insert into daily_metrics (
date,
total_users,
active_users,
new_users,
total_sessions,
total_page_views,
avg_session_duration_seconds,
bounce_rate
)
select
target_date,
(select count(*) from auth.users where created_at::date <= target_date),
(select count(distinct user_id) from analytics_events where created_at::date = target_date),
(select count(*) from auth.users where created_at::date = target_date),
(select count(*) from user_sessions where started_at::date = target_date),
(select count(*) from page_views where created_at::date = target_date),
(select avg(extract(epoch from (ended_at - started_at)))::int
from user_sessions
where started_at::date = target_date and ended_at is not null),
(select
(count(*) filter (where pv_count = 1)::float / nullif(count(*), 0) * 100)::decimal(5,2)
from (
select session_id, count(*) as pv_count
from page_views
where created_at::date = target_date
group by session_id
) session_page_counts)
on conflict (date) do update set
total_users = excluded.total_users,
active_users = excluded.active_users,
new_users = excluded.new_users,
total_sessions = excluded.total_sessions,
total_page_views = excluded.total_page_views,
avg_session_duration_seconds = excluded.avg_session_duration_seconds,
bounce_rate = excluded.bounce_rate,
calculated_at = now();
end;
$$ language plpgsql;
-- Calculate engagement score
create or replace function update_user_engagement()
returns void as $$
begin
insert into user_engagement (
user_id,
last_active_at,
total_sessions,
total_events,
days_active,
engagement_score
)
select
u.id,
(select max(created_at) from analytics_events where user_id = u.id),
(select count(*) from user_sessions where user_id = u.id),
(select count(*) from analytics_events where user_id = u.id),
(select count(distinct created_at::date) from analytics_events where user_id = u.id),
-- Engagement score formula
(
(select count(distinct created_at::date) from analytics_events where user_id = u.id) * 10 +
(select count(*) from analytics_events where user_id = u.id and created_at > now() - interval '7 days') * 2 +
(select count(*) from user_sessions where user_id = u.id and started_at > now() - interval '7 days') * 5
)
from auth.users u
where u.id in (select distinct user_id from analytics_events where created_at > now() - interval '30 days')
on conflict (user_id) do update set
last_active_at = excluded.last_active_at,
total_sessions = excluded.total_sessions,
total_events = excluded.total_events,
days_active = excluded.days_active,
engagement_score = excluded.engagement_score,
updated_at = now();
end;
$$ language plpgsql;
-- Feature usage increment function
create or replace function increment_feature_usage(
p_feature_name text,
p_user_id uuid
)
returns void as $$
begin
insert into feature_usage (feature_name, user_id, usage_count, first_used_at, last_used_at)
values (p_feature_name, p_user_id, 1, now(), now())
on conflict (feature_name, user_id) do update set
usage_count = feature_usage.usage_count + 1,
last_used_at = now();
end;
$$ language plpgsql;
-- Retention cohort analysis
create or replace function cohort_retention_analysis(
cohort_start date,
cohort_end date
)
returns table (
cohort_month text,
cohort_size bigint,
week_0 bigint,
week_1 bigint,
week_2 bigint,
week_3 bigint,
week_4 bigint
) as $$
begin
return query
with user_cohorts as (
select
id as user_id,
date_trunc('month', created_at) as cohort_month
from auth.users
where created_at::date between cohort_start and cohort_end
)
select
to_char(uc.cohort_month, 'YYYY-MM') as cohort_month,
count(distinct uc.user_id) as cohort_size,
count(distinct case when ae.week_number = 0 then ae.user_id end) as week_0,
count(distinct case when ae.week_number = 1 then ae.user_id end) as week_1,
count(distinct case when ae.week_number = 2 then ae.user_id end) as week_2,
count(distinct case when ae.week_number = 3 then ae.user_id end) as week_3,
count(distinct case when ae.week_number = 4 then ae.user_id end) as week_4
from user_cohorts uc
left join (
select
user_id,
floor(extract(epoch from (created_at - u.created_at)) / (7 * 24 * 60 * 60))::int as week_number
from analytics_events ae
join auth.users u on u.id = ae.user_id
) ae on ae.user_id = uc.user_id
group by uc.cohort_month
order by uc.cohort_month;
end;
$$ language plpgsql;Real-time Analytics Dashboard
// components/AnalyticsDashboard.tsx
import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'
interface DashboardMetrics {
activeUsers: number
totalSessions: number
totalPageViews: number
avgSessionDuration: number
}
export function AnalyticsDashboard() {
const [metrics, setMetrics] = useState<DashboardMetrics>({
activeUsers: 0,
totalSessions: 0,
totalPageViews: 0,
avgSessionDuration: 0,
})
const [recentEvents, setRecentEvents] = useState<any[]>([])
useEffect(() => {
loadMetrics()
loadRecentEvents()
// Subscribe to real-time events
const eventsChannel = supabase
.channel('analytics-events')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'analytics_events',
},
(payload) => {
setRecentEvents((current) => [payload.new, ...current.slice(0, 9)])
loadMetrics() // Refresh metrics
}
)
.subscribe()
return () => {
supabase.removeChannel(eventsChannel)
}
}, [])
async function loadMetrics() {
const today = new Date().toISOString().split('T')[0]
// Active users today
const { count: activeUsers } = await supabase
.from('analytics_events')
.select('user_id', { count: 'exact', head: true })
.gte('created_at', today)
// Total sessions today
const { count: totalSessions } = await supabase
.from('user_sessions')
.select('*', { count: 'exact', head: true })
.gte('started_at', today)
// Total page views today
const { count: totalPageViews } = await supabase
.from('page_views')
.select('*', { count: 'exact', head: true })
.gte('created_at', today)
// Average session duration
const { data: sessions } = await supabase
.from('user_sessions')
.select('started_at, ended_at')
.gte('started_at', today)
.not('ended_at', 'is', null)
const avgDuration = sessions?.length
? sessions.reduce((acc, s) => {
const duration =
(new Date(s.ended_at).getTime() - new Date(s.started_at).getTime()) / 1000
return acc + duration
}, 0) / sessions.length
: 0
setMetrics({
activeUsers: activeUsers || 0,
totalSessions: totalSessions || 0,
totalPageViews: totalPageViews || 0,
avgSessionDuration: Math.floor(avgDuration),
})
}
async function loadRecentEvents() {
const { data } = await supabase
.from('analytics_events')
.select('*')
.order('created_at', { ascending: false })
.limit(10)
if (data) setRecentEvents(data)
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Analytics Dashboard</h1>
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<MetricCard title="Active Users" value={metrics.activeUsers} />
<MetricCard title="Total Sessions" value={metrics.totalSessions} />
<MetricCard title="Page Views" value={metrics.totalPageViews} />
<MetricCard
title="Avg Session Duration"
value={`${metrics.avgSessionDuration}s`}
/>
</div>
{/* Recent Events */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
<div className="space-y-2">
{recentEvents.map((event) => (
<div
key={event.id}
className="flex justify-between items-center p-3 bg-gray-50 rounded"
>
<span className="font-medium">{event.event_name}</span>
<span className="text-sm text-gray-500">
{new Date(event.created_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
</div>
</div>
)
}
function MetricCard({ title, value }: { title: string; value: number | string }) {
return (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm text-gray-500 mb-2">{title}</h3>
<p className="text-3xl font-bold">{value}</p>
</div>
)
}Funnel and Conversion Analysis
-- Create funnel analysis view
create or replace function analyze_conversion_funnel(
start_date timestamp with time zone,
end_date timestamp with time zone
)
returns table (
step text,
users bigint,
conversion_rate decimal(5,2)
) as $$
with funnel_steps as (
select 'Visited Landing Page' as step, 1 as step_order,
count(distinct user_id) as users
from page_views
where page_url = '/' and created_at between start_date and end_date
union all
select 'Signed Up' as step, 2 as step_order,
count(distinct user_id) as users
from analytics_events
where event_name = 'user_signed_up' and created_at between start_date and end_date
union all
select 'Completed Onboarding' as step, 3 as step_order,
count(distinct user_id) as users
from analytics_events
where event_name = 'onboarding_completed' and created_at between start_date and end_date
union all
select 'Made First Purchase' as step, 4 as step_order,
count(distinct user_id) as users
from analytics_events
where event_name = 'purchase_completed' and created_at between start_date and end_date
),
first_step as (
select users as initial_users
from funnel_steps
where step_order = 1
)
select
fs.step,
fs.users,
(fs.users::decimal / first_step.initial_users * 100)::decimal(5,2) as conversion_rate
from funnel_steps fs
cross join first_step
order by fs.step_order;
$$ language sql;
-- Usage
select * from analyze_conversion_funnel(
'2026-01-01'::timestamp,
'2026-01-31'::timestamp
);
// React component for funnel visualization
import { useEffect, useState } from 'react'
import { supabase } from '../lib/supabase'
interface FunnelStep {
step: string
users: number
conversion_rate: number
}
export function ConversionFunnel() {
const [funnelData, setFunnelData] = useState<FunnelStep[]>([])
useEffect(() => {
loadFunnelData()
}, [])
async function loadFunnelData() {
const { data, error } = await supabase.rpc('analyze_conversion_funnel', {
start_date: new Date('2026-01-01').toISOString(),
end_date: new Date('2026-01-31').toISOString(),
})
if (data) setFunnelData(data)
}
return (
<div className="space-y-4">
{funnelData.map((step, index) => (
<div key={step.step} className="relative">
<div
className="bg-blue-500 text-white p-4 rounded"
style={{
width: `${step.conversion_rate}%`,
minWidth: '200px',
}}
>
<div className="flex justify-between">
<span className="font-medium">{step.step}</span>
<span>{step.users} users</span>
</div>
<div className="text-sm">{step.conversion_rate}%</div>
</div>
{index < funnelData.length - 1 && (
<div className="text-red-500 text-sm mt-1">
Drop-off: {(funnelData[index].users - funnelData[index + 1].users)} users
</div>
)}
</div>
))}
</div>
)
}Analytics Best Practices
- Track Meaningful Events: Focus on user actions driving business value not every click
- Use Consistent Naming: Establish naming conventions for events and properties maintaining clarity
- Implement Privacy Controls: Anonymize personal data and provide opt-out mechanisms complying with regulations
- Optimize Query Performance: Add indexes on frequently queried columns and use materialized views for aggregations
- Archive Old Data: Move historical data to separate tables maintaining query performance
- Validate Event Data: Implement schema validation preventing invalid or malformed events
- Monitor System Health: Track analytics system performance ensuring reliability
Common Analytics Issues
- Slow Dashboard Queries: Add indexes on date and user_id columns, use materialized views for aggregations
- Missing Events: Check client-side tracking implementation, verify RLS policies allow inserts, review error logs
- Inaccurate Metrics: Validate calculation logic, handle null values properly, deduplicate events if needed
- Large Table Size: Archive old events to separate tables, implement data retention policies, partition by date
Next Steps
- Automate Reports: Schedule with cron jobs
- Build Real-time Features: Use subscriptions
- Optimize Performance: Apply optimization techniques
- Secure Data: Implement security practices
Conclusion
Implementing analytics enables tracking user behavior, monitoring application performance, measuring feature adoption, and making data-driven decisions through event tracking, metrics aggregation, and real-time dashboards built directly with Supabase. By designing analytics schema storing events page views sessions and engagement metrics, implementing event tracking with client SDKs capturing user actions and properties, building real-time dashboards subscribing to live data updates, calculating key performance indicators including active users retention rates and conversion metrics, implementing funnel analysis understanding user journeys and drop-off points, creating materialized views optimizing query performance for large datasets, and respecting user privacy anonymizing data and providing opt-out mechanisms, you build comprehensive analytics. Self-hosted analytics advantages include maintaining full data ownership without third-party access, enabling custom metrics specific to business needs, providing real-time insights with subscriptions, integrating seamlessly with application data, and complying with privacy regulations GDPR and CCPA. Always track meaningful events driving business value, use consistent naming conventions, implement privacy controls, optimize query performance with indexes and materialized views, archive old data maintaining performance, validate event data preventing errors, and monitor system health. Analytics become essential when understanding user behavior, optimizing conversion funnels, measuring feature success, tracking application health, or building product-led growth strategies. Continue with automation, real-time, and optimization.
$ share --platform
$ cat /comments/ (0)
$ cat /comments/
// No comments found. Be the first!


