Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Track Analytics with Supabase

You can build a lightweight analytics system using Supabase by creating an events table, inserting tracking events from your frontend or Edge Functions, and querying aggregate data with SQL. Create a table with columns for event name, user ID, metadata (JSONB), and timestamp. Insert events via the JS client, and query them with SQL aggregate functions like COUNT, GROUP BY, and date_trunc. This approach avoids third-party analytics dependencies and keeps all data in your own database.

What you'll learn

  • How to design an analytics events table in Supabase
  • How to track page views and custom events from the frontend
  • How to query analytics data with SQL aggregate functions
  • How to build a simple analytics dashboard with Supabase queries
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

You can build a lightweight analytics system using Supabase by creating an events table, inserting tracking events from your frontend or Edge Functions, and querying aggregate data with SQL. Create a table with columns for event name, user ID, metadata (JSONB), and timestamp. Insert events via the JS client, and query them with SQL aggregate functions like COUNT, GROUP BY, and date_trunc. This approach avoids third-party analytics dependencies and keeps all data in your own database.

Building a Lightweight Analytics System with Supabase

Instead of relying on third-party analytics services, you can use Supabase itself to track user behavior, page views, and custom events. The approach is straightforward: create an events table, insert tracking records from your application, and query the data with SQL. This gives you full ownership of your analytics data, no cookie consent issues with third-party scripts, and the flexibility to track exactly what matters to your application.

Prerequisites

  • A Supabase project with the JS client configured
  • A frontend application where you want to add tracking
  • Basic understanding of SQL aggregate functions (COUNT, GROUP BY)

Step-by-step guide

1

Create the analytics events table

Start by creating a table to store analytics events. Use a JSONB column for flexible metadata so you can track different properties for different event types without schema changes. Add a timestamp with a default of now() so every event is automatically timestamped. Enable RLS and create a policy that allows authenticated users to insert events but not read other users' events. For public-facing analytics (like page views), you may also want an anon insert policy.

typescript
1-- Create the events table
2CREATE TABLE public.analytics_events (
3 id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
4 user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
5 event_name text NOT NULL,
6 page_url text,
7 metadata jsonb DEFAULT '{}'::jsonb,
8 created_at timestamptz DEFAULT now()
9);
10
11-- Enable RLS
12ALTER TABLE public.analytics_events ENABLE ROW LEVEL SECURITY;
13
14-- Allow authenticated users to insert their own events
15CREATE POLICY "Users can insert own events"
16 ON public.analytics_events FOR INSERT
17 TO authenticated
18 WITH CHECK ((SELECT auth.uid()) = user_id);
19
20-- Allow anonymous page view tracking
21CREATE POLICY "Anonymous users can insert page views"
22 ON public.analytics_events FOR INSERT
23 TO anon
24 WITH CHECK (user_id IS NULL AND event_name = 'page_view');
25
26-- Add indexes for common queries
27CREATE INDEX idx_events_name ON public.analytics_events (event_name);
28CREATE INDEX idx_events_created ON public.analytics_events (created_at);
29CREATE INDEX idx_events_user ON public.analytics_events (user_id);

Expected result: The analytics_events table is created with RLS policies allowing both authenticated and anonymous event insertion.

2

Track page views from the frontend

Create a tracking function that inserts a page view event every time the user navigates to a new page. In React apps, call this function inside a useEffect in your layout or router component. Include the page URL and any relevant metadata like the referrer, screen size, or UTM parameters. Keep the insert lightweight — fire and forget without awaiting the result to avoid slowing down page loads.

typescript
1import { supabase } from '@/lib/supabase'
2
3// Track a page view (fire and forget)
4export function trackPageView(url: string, metadata?: Record<string, any>) {
5 supabase
6 .from('analytics_events')
7 .insert({
8 event_name: 'page_view',
9 page_url: url,
10 metadata: {
11 referrer: document.referrer || null,
12 screen_width: window.innerWidth,
13 ...metadata,
14 },
15 })
16 .then(({ error }) => {
17 if (error) console.warn('Analytics error:', error.message)
18 })
19}
20
21// Use in React (in your layout or router)
22import { useEffect } from 'react'
23import { useLocation } from 'react-router-dom'
24
25function AnalyticsTracker() {
26 const location = useLocation()
27
28 useEffect(() => {
29 trackPageView(location.pathname + location.search)
30 }, [location])
31
32 return null
33}

Expected result: Every page navigation triggers an insert into the analytics_events table with the page URL and metadata.

3

Track custom events for user actions

Beyond page views, track specific user actions like button clicks, form submissions, feature usage, or purchases. Create a generic trackEvent function that accepts an event name and optional metadata. For authenticated users, include their user_id so you can analyze per-user behavior. Store action-specific data in the metadata JSONB column for flexible querying.

typescript
1import { supabase } from '@/lib/supabase'
2
3// Track any custom event
4export async function trackEvent(
5 eventName: string,
6 metadata?: Record<string, any>
7) {
8 const { data: { user } } = await supabase.auth.getUser()
9
10 supabase
11 .from('analytics_events')
12 .insert({
13 event_name: eventName,
14 user_id: user?.id || null,
15 metadata: metadata || {},
16 })
17 .then(({ error }) => {
18 if (error) console.warn('Track event error:', error.message)
19 })
20}
21
22// Usage examples:
23trackEvent('signup_completed', { plan: 'pro' })
24trackEvent('project_created', { project_name: 'My App' })
25trackEvent('feature_used', { feature: 'export_csv' })
26trackEvent('button_clicked', { button: 'upgrade_plan', page: '/settings' })

Expected result: Custom events are inserted into the analytics_events table with the event name, user ID, and metadata.

4

Query analytics data with SQL aggregations

Use the SQL Editor or database functions to query your analytics data. Common queries include total page views per day, most popular pages, user activity counts, and event funnels. PostgreSQL's date_trunc function is invaluable for grouping events by time period. Create a database function for complex queries that you can call from your frontend via supabase.rpc().

typescript
1-- Page views per day (last 30 days)
2SELECT
3 date_trunc('day', created_at)::date AS day,
4 COUNT(*) AS views
5FROM public.analytics_events
6WHERE event_name = 'page_view'
7 AND created_at >= now() - interval '30 days'
8GROUP BY day
9ORDER BY day DESC;
10
11-- Most popular pages
12SELECT
13 page_url,
14 COUNT(*) AS views
15FROM public.analytics_events
16WHERE event_name = 'page_view'
17 AND created_at >= now() - interval '7 days'
18GROUP BY page_url
19ORDER BY views DESC
20LIMIT 20;
21
22-- Active users per day
23SELECT
24 date_trunc('day', created_at)::date AS day,
25 COUNT(DISTINCT user_id) AS active_users
26FROM public.analytics_events
27WHERE user_id IS NOT NULL
28 AND created_at >= now() - interval '30 days'
29GROUP BY day
30ORDER BY day DESC;
31
32-- Event counts by type
33SELECT
34 event_name,
35 COUNT(*) AS total
36FROM public.analytics_events
37WHERE created_at >= now() - interval '7 days'
38GROUP BY event_name
39ORDER BY total DESC;

Expected result: SQL queries return aggregated analytics data grouped by time period, page, user, or event type.

5

Create a database function for dashboard queries

Wrap your analytics queries in PostgreSQL functions so they can be called from the frontend via supabase.rpc(). This is more secure than exposing raw SELECT access to the events table, and it lets you predefine the exact queries your dashboard needs. Use security definer if the function needs to bypass RLS, and restrict execution to authenticated users.

typescript
1-- Create a function to get daily page views
2CREATE OR REPLACE FUNCTION public.get_daily_page_views(
3 days_back integer DEFAULT 30
4)
5RETURNS TABLE (day date, views bigint)
6LANGUAGE sql
7SECURITY DEFINER
8SET search_path = ''
9AS $$
10 SELECT
11 date_trunc('day', created_at)::date AS day,
12 COUNT(*) AS views
13 FROM public.analytics_events
14 WHERE event_name = 'page_view'
15 AND created_at >= now() - make_interval(days => days_back)
16 GROUP BY day
17 ORDER BY day DESC;
18$$;
19
20-- Restrict to authenticated users only
21REVOKE EXECUTE ON FUNCTION public.get_daily_page_views FROM anon;
22
23-- Call from the frontend:
24-- const { data } = await supabase.rpc('get_daily_page_views', { days_back: 30 })

Expected result: The function is callable via supabase.rpc() and returns aggregated analytics data for the specified time period.

Complete working example

analytics-tracker.ts
1// Complete analytics tracking module for Supabase
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7)
8
9interface EventMetadata {
10 [key: string]: string | number | boolean | null
11}
12
13// Track a page view (fire and forget)
14export function trackPageView(
15 url: string,
16 metadata?: EventMetadata
17) {
18 const payload = {
19 event_name: 'page_view',
20 page_url: url,
21 metadata: {
22 referrer: typeof document !== 'undefined' ? document.referrer : null,
23 ...metadata,
24 },
25 }
26
27 supabase.from('analytics_events').insert(payload).then(({ error }) => {
28 if (error) console.warn('[Analytics] Page view error:', error.message)
29 })
30}
31
32// Track a custom event with user context
33export async function trackEvent(
34 eventName: string,
35 metadata?: EventMetadata
36) {
37 const { data: { user } } = await supabase.auth.getUser()
38
39 const payload = {
40 event_name: eventName,
41 user_id: user?.id || null,
42 metadata: metadata || {},
43 }
44
45 supabase.from('analytics_events').insert(payload).then(({ error }) => {
46 if (error) console.warn(`[Analytics] ${eventName} error:`, error.message)
47 })
48}
49
50// Fetch daily page views for a dashboard
51export async function getDailyPageViews(daysBack: number = 30) {
52 const { data, error } = await supabase.rpc('get_daily_page_views', {
53 days_back: daysBack,
54 })
55 if (error) throw new Error(`Analytics query failed: ${error.message}`)
56 return data as { day: string; views: number }[]
57}
58
59// Fetch top pages
60export async function getTopPages(limit: number = 20) {
61 const { data, error } = await supabase.rpc('get_top_pages', {
62 page_limit: limit,
63 })
64 if (error) throw new Error(`Top pages query failed: ${error.message}`)
65 return data as { page_url: string; views: number }[]
66}

Common mistakes when tracking Analytics with Supabase

Why it's a problem: Awaiting analytics inserts in the render path, which slows down page navigation

How to avoid: Use fire-and-forget pattern with .then() instead of await. Analytics writes should never block the user experience.

Why it's a problem: Not adding indexes on event_name and created_at columns, causing slow aggregate queries

How to avoid: Add btree indexes on event_name, created_at, and user_id. Without indexes, GROUP BY and date range queries scan the entire table.

Why it's a problem: Storing high-cardinality data like full request bodies in the metadata column, bloating the table

How to avoid: Only store relevant, low-cardinality metadata. Avoid storing full request/response bodies, large JSON objects, or PII.

Best practices

  • Use fire-and-forget pattern for analytics inserts so they never block the user experience
  • Add indexes on event_name, created_at, and user_id for fast aggregate queries
  • Use a consistent naming convention for events (action_object format) to simplify querying
  • Store event-specific data in the JSONB metadata column for flexibility without schema changes
  • Create database functions for dashboard queries and call them via supabase.rpc() for security
  • Set up a pg_cron job to delete old analytics data (e.g., older than 90 days) to control table size
  • Use ON DELETE SET NULL for the user_id foreign key to preserve analytics data when users are deleted
  • Consider batching high-volume events (like scroll tracking) to reduce insert frequency

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I want to build a simple analytics system using Supabase to track page views and custom events in my React app. Show me the database schema, RLS policies, tracking functions, and SQL queries for a basic analytics dashboard.

Supabase Prompt

Create an analytics_events table in Supabase with RLS policies, add tracking functions for page views and custom events, and write SQL aggregate queries for daily views, top pages, and active users. Include a database function I can call from the frontend.

Frequently asked questions

Is Supabase a good choice for analytics tracking?

Supabase works well for lightweight analytics (page views, custom events, feature tracking) in small to medium apps. For high-traffic sites with millions of events per day, consider a dedicated analytics database like ClickHouse or a service like Segment.

Will analytics inserts slow down my application?

No, if you use the fire-and-forget pattern. Do not await the insert — use .then() so the database write happens in the background and does not block page rendering or navigation.

How do I handle analytics for unauthenticated users?

Create an RLS policy that allows the anon role to insert events with a null user_id. You can generate a random anonymous ID in the browser and store it in localStorage to track sessions across pages.

How do I prevent the analytics table from growing too large?

Set up a pg_cron job to delete events older than your retention period (e.g., 90 days). You can also archive old data to a separate table or export it before deletion.

Can I track events from Supabase Edge Functions?

Yes. Create a Supabase client inside your Edge Function using the service role key and insert events directly. This is useful for tracking server-side events like webhook receipts or scheduled job completions.

How is this different from using Google Analytics?

This approach stores all data in your own database with no third-party scripts, no cookie consent banners needed, and full SQL access for custom queries. Google Analytics provides more out-of-the-box features but shares data with Google.

Can RapidDev help me build a custom analytics dashboard with Supabase?

Yes. RapidDev can design your analytics schema, build tracking integrations, create SQL-powered dashboards, and set up data retention policies for your Supabase-based analytics system.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help with your project?

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.