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
Create the analytics events table
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.
1-- Create the events table2CREATE 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);1011-- Enable RLS12ALTER TABLE public.analytics_events ENABLE ROW LEVEL SECURITY;1314-- Allow authenticated users to insert their own events15CREATE POLICY "Users can insert own events"16 ON public.analytics_events FOR INSERT17 TO authenticated18 WITH CHECK ((SELECT auth.uid()) = user_id);1920-- Allow anonymous page view tracking21CREATE POLICY "Anonymous users can insert page views"22 ON public.analytics_events FOR INSERT23 TO anon24 WITH CHECK (user_id IS NULL AND event_name = 'page_view');2526-- Add indexes for common queries27CREATE 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.
Track page views from the frontend
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.
1import { supabase } from '@/lib/supabase'23// Track a page view (fire and forget)4export function trackPageView(url: string, metadata?: Record<string, any>) {5 supabase6 .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}2021// Use in React (in your layout or router)22import { useEffect } from 'react'23import { useLocation } from 'react-router-dom'2425function AnalyticsTracker() {26 const location = useLocation()2728 useEffect(() => {29 trackPageView(location.pathname + location.search)30 }, [location])3132 return null33}Expected result: Every page navigation triggers an insert into the analytics_events table with the page URL and metadata.
Track custom events for user actions
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.
1import { supabase } from '@/lib/supabase'23// Track any custom event4export async function trackEvent(5 eventName: string,6 metadata?: Record<string, any>7) {8 const { data: { user } } = await supabase.auth.getUser()910 supabase11 .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}2122// 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.
Query analytics data with SQL aggregations
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().
1-- Page views per day (last 30 days)2SELECT3 date_trunc('day', created_at)::date AS day,4 COUNT(*) AS views5FROM public.analytics_events6WHERE event_name = 'page_view'7 AND created_at >= now() - interval '30 days'8GROUP BY day9ORDER BY day DESC;1011-- Most popular pages12SELECT13 page_url,14 COUNT(*) AS views15FROM public.analytics_events16WHERE event_name = 'page_view'17 AND created_at >= now() - interval '7 days'18GROUP BY page_url19ORDER BY views DESC20LIMIT 20;2122-- Active users per day23SELECT24 date_trunc('day', created_at)::date AS day,25 COUNT(DISTINCT user_id) AS active_users26FROM public.analytics_events27WHERE user_id IS NOT NULL28 AND created_at >= now() - interval '30 days'29GROUP BY day30ORDER BY day DESC;3132-- Event counts by type33SELECT34 event_name,35 COUNT(*) AS total36FROM public.analytics_events37WHERE created_at >= now() - interval '7 days'38GROUP BY event_name39ORDER BY total DESC;Expected result: SQL queries return aggregated analytics data grouped by time period, page, user, or event type.
Create a database function for dashboard queries
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.
1-- Create a function to get daily page views2CREATE OR REPLACE FUNCTION public.get_daily_page_views(3 days_back integer DEFAULT 304)5RETURNS TABLE (day date, views bigint)6LANGUAGE sql7SECURITY DEFINER8SET search_path = ''9AS $$10 SELECT11 date_trunc('day', created_at)::date AS day,12 COUNT(*) AS views13 FROM public.analytics_events14 WHERE event_name = 'page_view'15 AND created_at >= now() - make_interval(days => days_back)16 GROUP BY day17 ORDER BY day DESC;18$$;1920-- Restrict to authenticated users only21REVOKE EXECUTE ON FUNCTION public.get_daily_page_views FROM anon;2223-- 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
1// Complete analytics tracking module for Supabase2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!7)89interface EventMetadata {10 [key: string]: string | number | boolean | null11}1213// Track a page view (fire and forget)14export function trackPageView(15 url: string,16 metadata?: EventMetadata17) {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 }2627 supabase.from('analytics_events').insert(payload).then(({ error }) => {28 if (error) console.warn('[Analytics] Page view error:', error.message)29 })30}3132// Track a custom event with user context33export async function trackEvent(34 eventName: string,35 metadata?: EventMetadata36) {37 const { data: { user } } = await supabase.auth.getUser()3839 const payload = {40 event_name: eventName,41 user_id: user?.id || null,42 metadata: metadata || {},43 }4445 supabase.from('analytics_events').insert(payload).then(({ error }) => {46 if (error) console.warn(`[Analytics] ${eventName} error:`, error.message)47 })48}4950// Fetch daily page views for a dashboard51export 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}5859// Fetch top pages60export 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation