Feature spec
IntermediateCategory
analytics-admin
Build with AI
3-6 hours with FlutterFlow
Custom build
1-2 weeks custom dev
Running cost
$0/mo up to 1,000 users; $25-50/mo at 10,000 users
Works on
Everything it takes to ship a Leaderboard — parts, prompts, and real costs.
A mobile leaderboard needs four pieces: a Supabase materialized view with RANK() that aggregates scores, a Flutter list with animated row reordering, a current-user sticky row showing rank even when outside the top N, and Firebase Cloud Messaging for rank-change push notifications. With FlutterFlow you can ship this in 3-5 hours for $0/month — costs only rise past 10,000 users when Supabase Pro is needed for realtime connection headroom.
What a Leaderboard Feature Actually Is
A leaderboard ranks users by score, XP, or completions and shows them where they stand relative to everyone else — the social proof mechanism that turns individual progress into competitive engagement. The product decisions are: what entity is scored (points, challenges completed, purchases), whether the leaderboard is global or scoped to a group, how often ranks refresh (real-time vs every 5 minutes), and what happens when the user is ranked #247 — do they still see their position? The ranked list is the easy half; the current-user sticky row, the animated rank transitions, and the deep-link from a push notification are where first builds reliably break.
What users consider table stakes in 2026
- Top-N ranked list with rank number, avatar, username, and score visible on a single row without horizontal scrolling
- Current user's rank shown in a sticky footer row even when they are outside the top N (e.g. 'You are #47 · 1,240 pts')
- Real-time rank updates as other users score points — no pull-to-refresh required
- Time-scope filter tabs (All Time, This Week, Today) that switch the underlying query without reloading the screen
- Smooth animated rank position changes when scores update — rows slide up or down rather than snapping
- Deep link from a rank-change push notification that opens the app directly to the leaderboard with the user's row scrolled into view
Anatomy of the Feature
Six components. FlutterFlow handles the list widget and basic Supabase queries well on a first prompt. The current-user sticky row, FCM deep-link, and animated reordering reliably require custom Dart action blocks.
Leaderboard list widget
UIFlutter ListView.builder rendering each ranked user as a row with CircleAvatar (from Supabase Storage URL), Text for rank number, Text for username, and Text for total score. Animated row reordering uses the implicitly_animated_list package or AnimatedList — provides smooth slide transitions when rank positions change on a Realtime update.
Note: implicitly_animated_list is easier to integrate than AnimatedList for data-driven reordering — pass it the sorted list and it handles the item transitions automatically.
Score aggregation view
DataSupabase PostgreSQL materialized view that aggregates scores: SELECT user_id, username, avatar_url, SUM(points) AS total_score, RANK() OVER (ORDER BY SUM(points) DESC) AS rank FROM user_events JOIN users ON users.id = user_events.user_id GROUP BY user_id, username, avatar_url. Refreshed every 5 minutes via Supabase pg_cron (REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_view) to avoid locking the table during high-write periods.
Note: Use REFRESH MATERIALIZED VIEW CONCURRENTLY — without CONCURRENTLY, the refresh locks all reads for the duration, causing the leaderboard to show a loading state for 1-3 seconds during refresh.
Current user rank query
DataA separate Supabase query that runs alongside the top-N fetch: SELECT rank, total_score FROM leaderboard_view WHERE user_id = auth.uid(). Returns the current user's rank and score regardless of whether they appear in the top 50. Displayed as a sticky footer row with distinct background color so it stands out from the ranked list above.
Note: This must be a separate query — do not try to append the user's row to the LIMIT 50 result and deduplicate; the Flutter widget needs to know whether the user is in the list before deciding whether to show the footer.
Realtime subscription
BackendSupabase Realtime channel subscribed to the underlying user_events table (INSERT events). When a new score event arrives, the Flutter StreamBuilder triggers a re-fetch of the leaderboard_view. Debounced to 2 seconds to avoid thrashing the UI during burst scoring (e.g. rapid tap games).
Note: Materialized views do not emit Realtime events — subscribe to user_events (the source table) and re-query the view on each INSERT event. See Gotchas for the full explanation.
Time-scope filter
BackendFlutterFlow page state variable (scopeFilter: 'all_time' | 'weekly' | 'daily') that controls the Supabase query. Weekly and daily scopes add .gte('created_at', startOfWeek) to the user_events query before aggregation. Week boundaries are calculated in UTC to ensure consistent resets.
Note: Time-scoped leaderboards need separate views (or parameterized queries) for each scope — a single materialized view cannot be filtered post-aggregation without recalculating the rank.
Push notification deep link
ServiceFirebase Cloud Messaging (FCM) via the firebase_messaging Flutter package. When a user's rank improves (detected by comparing old vs new rank after a pg_cron refresh), a Supabase Edge Function sends an FCM notification payload including the new rank and a deep link URL. The Flutter app handles the notification in FirebaseMessaging.onMessageOpenedApp and navigates to LeaderboardPage with the current user's row scrolled into view.
Note: Cold-start navigation requires FirebaseMessaging.instance.getInitialMessage() in main() — onMessageOpenedApp does not fire if the app was fully closed when the notification arrived.
The data model
Three tables plus one materialized view. The materialized view is refreshed by pg_cron every 5 minutes, keeping rank calculations fast even at 10,000+ users. Paste this into the Supabase SQL Editor and run it before building:
1create table public.users (2 id uuid primary key references auth.users(id) on delete cascade,3 username text not null unique,4 avatar_url text5);67create table public.user_events (8 id uuid primary key default gen_random_uuid(),9 user_id uuid references public.users(id) on delete cascade not null,10 event_type text not null,11 points int not null default 0,12 created_at timestamptz not null default now()13);1415-- RLS16alter table public.user_events enable row level security;1718create policy "Users read own events"19 on public.user_events for select20 using (auth.uid() = user_id);2122create policy "Users insert own events"23 on public.user_events for insert24 with check (auth.uid() = user_id);2526-- Materialized leaderboard view27create materialized view public.leaderboard_view as28 select29 u.id as user_id,30 u.username,31 u.avatar_url,32 coalesce(sum(e.points), 0) as total_score,33 rank() over (order by coalesce(sum(e.points), 0) desc) as rank34 from public.users u35 left join public.user_events e on e.user_id = u.id36 group by u.id, u.username, u.avatar_url37with data;3839create unique index leaderboard_view_user_idx on public.leaderboard_view (user_id);4041-- Allow all authenticated users to read the leaderboard42create policy "Auth users read leaderboard"43 on public.leaderboard_view for select44 using (auth.role() = 'authenticated');4546-- pg_cron refresh every 5 minutes (enable pg_cron extension first)47select cron.schedule(48 'refresh-leaderboard',49 '*/5 * * * *',50 'REFRESH MATERIALIZED VIEW CONCURRENTLY public.leaderboard_view'51);5253-- Index for time-scoped queries on user_events54create index user_events_time_idx on public.user_events (created_at, user_id);55create index user_events_user_score_idx on public.user_events (user_id, points);Heads up: The UNIQUE index on leaderboard_view (user_id) is required for REFRESH CONCURRENTLY — without it, Postgres refuses to perform a concurrent refresh. Enable pg_cron in Supabase Dashboard → Database → Extensions before running the cron schedule call.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
The primary path for a mobile leaderboard: native Flutter list widgets with smooth animations, direct Supabase Backend Query with Realtime stream, and pg_cron materialized view for scale-safe aggregation.
Step by step
- 1Create a FlutterFlow project connected to your Supabase project and import the leaderboard_view and user_events tables
- 2Add a LeaderboardPage with a Column layout: scope filter row (three TextButton tabs for All Time / This Week / Today) at the top, a ListView bound to a Backend Query on leaderboard_view ordered by rank ASC with LIMIT 50, then a sticky Container at the bottom showing the current user's rank from a separate Backend Query filtered by user_id = currentUserUid
- 3Switch the main leaderboard Backend Query to Realtime Stream mode — it will rebuild the list whenever user_events receives a new INSERT (re-fetch the view)
- 4In the Action editor for each row, add a conditional: if the row's user_id equals currentUserUid, highlight it with a different background color even within the top 50 list
- 5Add a custom action block for FCM deep-link handling: call FirebaseMessaging.instance.getInitialMessage() in the app initialization action and navigate to LeaderboardPage if the notification payload contains 'screen': 'leaderboard'
- 6Enable pg_cron in Supabase Dashboard → Database → Extensions and run the cron schedule SQL from this page to activate the 5-minute materialized view refresh
Where this path bites
- Code export requires FlutterFlow Pro ($70/mo) — the free plan runs only in the FlutterFlow preview app
- Animated row reordering (rows sliding up/down on rank change) requires a Dart custom code block using implicitly_animated_list — FlutterFlow's visual builder does not expose AnimatedList
- FCM push notification wiring for rank-change alerts requires a Supabase Edge Function that FlutterFlow cannot generate; it must be written separately in the Supabase Dashboard → Edge Functions editor
Third-party services you'll need
The leaderboard runs primarily on Supabase. Firebase Cloud Messaging is free for push notifications. Redis caching is optional for high-read apps:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Leaderboard materialized view, pg_cron refresh every 5 minutes, Realtime subscription on user_events, RLS policies | Free tier: 500MB DB, 2 projects, 200 concurrent Realtime connections | Pro $25/mo (8GB DB, up to 500 concurrent Realtime connections) |
| Firebase Cloud Messaging (FCM) | Push notifications when a user moves up in rank — deep links directly to their leaderboard position | Free up to 250,000 messages/month | Free (no paid tier for FCM message volume) |
| Upstash Redis (optional) | Cache the top-100 leaderboard list to reduce Supabase read load for high-traffic apps | Free tier: 10,000 commands/day | Pro $0.2/100,000 commands (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers the materialized view storage and Realtime connections. FCM is free. pg_cron refresh runs within free tier limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Leaderboard rank never updates in real time
Symptom: The Supabase Realtime subscription is placed on the materialized view (leaderboard_view) rather than the source table (user_events). Materialized views are regular PostgreSQL relations — they do not emit Realtime postgres_changes events because they are not updated row-by-row. The subscription fires on nothing, and the list never refreshes.
Fix: Subscribe to user_events (the source table) for INSERT events via Supabase Realtime. When an event fires, re-query the leaderboard_view in the Flutter StreamBuilder callback. The view returns pre-calculated ranks so the re-query is fast. Alternatively, poll the view every 30 seconds with a Flutter Timer if Realtime connection count is a concern.
Current user's rank row is missing when they are outside the top 50
Symptom: The leaderboard query fetches LIMIT 50 rows. If the current user is ranked #247, they do not appear in the results — and without a separate query for their own rank, the UI shows no indication of where they stand. This is the most common UX complaint about leaderboards built with AI tools.
Fix: Run two queries in parallel: one for the top 50 (leaderboard_view ORDER BY rank ASC LIMIT 50), and one for the current user's own row (leaderboard_view WHERE user_id = auth.uid()). In the Flutter widget, show the top-50 list, then add a sticky footer Container that shows the user's rank row — but only if their user_id does not already appear in the top 50.
AnimatedList crashes on first load
Symptom: FlutterFlow's AnimatedList widget requires an initial data snapshot to exist before the Realtime subscription starts delivering updates. If the Realtime stream fires before the initial fetch completes — common on fast connections — the widget throws a RangeError because the list has 0 items but the animation tries to insert at position N.
Fix: Fetch the initial leaderboard list synchronously (Backend Query in page load action), store it in a page state variable, and only then attach the Realtime stream. Show a loading shimmer (FlutterFlow Skeleton widget) while the initial fetch is in progress.
Scores inflate from rapid double-taps
Symptom: The Flutter action that awards points fires on every button tap, including rapid double-taps or network-slow multiple taps. A single user action inserts 3-5 point events in 200ms — inflating their score and rank in a way that is invisible to the player but obvious in the raw data.
Fix: Add two layers of protection: client-side (disable the scoring button for 1 second after tap using a page state boolean), and server-side (in the Supabase INSERT policy or an Edge Function, check whether a row with the same event_type + user_id exists within the last 1 second before inserting).
FCM deep link navigates to the wrong screen on cold start
Symptom: FirebaseMessaging.onMessageOpenedApp only fires when the user opens a notification while the app is running in the background. If the app is completely closed (cold start) when the notification arrives, this stream never fires — the app opens to the default home screen and the user has to navigate to the leaderboard manually.
Fix: Add FirebaseMessaging.instance.getInitialMessage() in the Flutter app's main() function (before runApp). If it returns a non-null RemoteMessage with 'screen': 'leaderboard' in the data payload, push LeaderboardPage as the initial route.
Best practices
Always run two parallel queries — top-N list and current user's own rank — and merge them in the widget rather than trying to fetch a variable-size list that always includes the current user
Use REFRESH MATERIALIZED VIEW CONCURRENTLY (not without CONCURRENTLY) so the pg_cron refresh does not block reads during the 100-500ms refresh window
Create a UNIQUE index on leaderboard_view (user_id) before attempting a concurrent refresh — Postgres requires it and will throw an error without it
Debounce the Realtime-triggered re-query by 2 seconds during burst scoring events — otherwise a rapid-point game causes a UI re-render on every single tap
Store avatar_url in the users table (copied from auth.users metadata on signup) so the leaderboard view join never needs to hit the auth schema at query time
Document that leaderboard resets (weekly, daily) happen at midnight UTC and show this in the filter tab label — users in UTC-8 will see the weekly reset at 4 PM local time and need the context
Cap the top-N list at 100 rows maximum — a leaderboard showing 10,000 rows is not a leaderboard, it is a user directory; paginated deep browsing belongs in a separate analytics view
When You Need Custom Development
FlutterFlow handles global leaderboards with point-based scoring cleanly. You outgrow it when score integrity, complex weighting, or competitive prize pools enter the picture:
- The leaderboard drives real monetary prizes or competitive entry fees — score integrity must be verifiable and anti-cheat logic must detect bot-like scoring velocity in real time
- Scoring requires a time-decay function (recent activity counts more than old activity) or weighted event types — the materialized view approach cannot express arbitrary weighting without complex SQL
- Multi-region leaderboard with timezone-adjusted weekly resets — each region's week boundary differs by up to 26 hours
- Integration with an external game backend (PlayFab, GameSparks) where the authoritative score source is not Supabase
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How do I make the leaderboard update in real time without the user refreshing?
Subscribe to Supabase Realtime on the user_events table for INSERT events. When a new event fires, re-query the leaderboard_view (which has pre-calculated RANK values). Wrap the Flutter list in a StreamBuilder so it rebuilds automatically. Note: you subscribe to user_events, not the materialized view — materialized views do not emit Realtime events.
How do I show a user their own rank when they are not in the top 50?
Run a second Supabase query alongside the top-50 fetch: SELECT rank, total_score FROM leaderboard_view WHERE user_id = auth.uid(). Show this result in a sticky footer row at the bottom of the leaderboard screen with a distinct background. Only show the footer if the user's user_id does not already appear in the top-50 results.
Can I have separate leaderboards for different groups or teams?
Yes — add a group_id column to user_events and create a separate materialized view filtered by group_id. Alternatively, add a WHERE clause to the leaderboard query based on the user's team membership. If groups are dynamic (users can switch teams), ensure historical events carry the group_id that was active when the event occurred.
How do I prevent users from gaming the score system?
Add two guards: client-side (disable the scoring button for 1 second after each tap) and server-side (in the Supabase Edge Function or INSERT policy, reject events from the same user of the same type within 1 second). For competitive prize leaderboards, log every event with a server timestamp and run a nightly anomaly detection query flagging users whose scoring velocity exceeds 3 standard deviations from the mean.
Can I send a push notification when a user moves up in the rankings?
Yes — after each pg_cron leaderboard refresh, a Supabase Edge Function compares old vs new rank for each user. If rank improved, it sends an FCM notification via the Firebase Admin SDK with the new rank in the payload. Set up the Edge Function in Supabase Dashboard → Edge Functions and trigger it from the cron job.
How do I add weekly and monthly leaderboard resets?
Create separate materialized views for each time scope — leaderboard_weekly_view adds WHERE created_at >= date_trunc('week', now()) to the user_events join. Refresh them via separate pg_cron jobs. In the Flutter UI, switch between views based on the selected tab. Weekly views reset automatically because the WHERE clause advances with the clock — no explicit reset needed.
How do I show animated rank changes when positions shift?
Use the implicitly_animated_list package in Flutter: pass it a sorted list keyed by user_id and it automatically animates items sliding up or down when their position changes. On each Realtime event, re-fetch the leaderboard_view and update the Flutter widget's data source — the animation handles the rest.
Need this feature production-ready?
RapidDev builds a leaderboard into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.