# How to Add User Progress Tracking to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

User progress tracking needs four pieces: a progress bar component, a table recording completed steps per user, a calculation function that returns 0-100%, and streak/badge logic for retention. With Lovable or FlutterFlow you can ship a working system in 4-8 hours for $0/month on Supabase's free tier — costs only rise past 1,000 users when realtime connection limits are hit.

## What User Progress Tracking Actually Is

Progress tracking shows users how far along they are in a course, onboarding flow, or workout program — with a visual bar or ring that updates the moment they complete each step. The design decision behind it is retention: users who see 60% complete are far more likely to return than users staring at an unstructured list. The feature has three layers: the visual component (bar, ring, step dots), the data layer recording which steps each user finished, and optional engagement hooks like streaks and milestone badges. The visual part is the easy half; making progress persist across devices and sessions is where most first builds break.

## Anatomy of the Feature

Six components across the UI, data, and backend layers. Lovable and FlutterFlow handle the visual and data layers well on a first prompt; the streak cron and milestone badge engine reliably need a second pass.

- **Progress bar / ring component** (ui): Renders completion percentage as a filled bar or circular ring. On web: shadcn/ui Progress component (built on Radix UI) with an animated fill driven by a 0-100 value. On mobile: Flutter's LinearProgressIndicator or CircularProgressIndicator with an AnimatedContainer wrapping it for smooth transitions.
- **Steps or modules table** (data): PostgreSQL table tracking which steps each user has completed: user_id, course_id, step_id, status (started/completed), and completed_at timestamp. Queried via Supabase JS client (.from('user_progress').select()) or FlutterFlow's Supabase Backend Query action.
- **Progress calculation function** (backend): A Supabase Edge Function (calculate_progress) or Next.js Server Action that counts completed steps / total steps per user per course and returns a 0-100 float. Guards against division by zero when total_steps is NULL or 0.
- **Milestone / badge engine** (backend): Evaluates completion thresholds (25%, 50%, 100%) after each step is marked complete and inserts rows into user_achievements when a threshold is crossed. Emits a Supabase Realtime event on the user_achievements channel so the frontend can show a badge pop-up without polling.
- **Streak tracker** (data): Records daily activity in a user_streaks table (user_id, current_streak INT, longest_streak INT, last_active_date DATE). A pg_cron job running daily at 00:01 UTC sets current_streak = 0 for users whose last_active_date is before yesterday, preserving longest_streak if current was higher.
- **RLS policy layer** (backend): Row-Level Security on user_progress, user_achievements, and user_streaks ensures every SELECT, INSERT, and UPDATE is scoped to auth.uid() = user_id. Configured in Supabase Dashboard → Authentication → Policies.

## Data model

Four tables covering courses, per-step progress, badges, and streaks. Paste this into the Supabase SQL Editor (Dashboard → SQL Editor → New Query) and run it before prompting your AI tool:

```sql
create table public.courses (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  total_steps int not null default 1
);

create table public.user_progress (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  course_id uuid references public.courses(id) on delete cascade not null,
  step_id text not null,
  status text not null default 'completed' check (status in ('started', 'completed')),
  completed_at timestamptz,
  created_at timestamptz not null default now(),
  unique (user_id, course_id, step_id)
);

create table public.user_achievements (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  badge_slug text not null,
  earned_at timestamptz not null default now(),
  unique (user_id, badge_slug)
);

create table public.user_streaks (
  user_id uuid primary key references auth.users(id) on delete cascade,
  current_streak int not null default 0,
  longest_streak int not null default 0,
  last_active_date date
);

-- RLS
alter table public.user_progress enable row level security;
alter table public.user_achievements enable row level security;
alter table public.user_streaks enable row level security;

create policy "Users read own progress"
  on public.user_progress for select
  using (auth.uid() = user_id);

create policy "Users insert own progress"
  on public.user_progress for insert
  with check (auth.uid() = user_id);

create policy "Users update own progress"
  on public.user_progress for update
  using (auth.uid() = user_id);

create policy "Users read own achievements"
  on public.user_achievements for select
  using (auth.uid() = user_id);

create policy "Users read own streaks"
  on public.user_streaks for select
  using (auth.uid() = user_id);

create policy "Users update own streaks"
  on public.user_streaks for update
  using (auth.uid() = user_id);

create policy "Users insert own streaks"
  on public.user_streaks for insert
  with check (auth.uid() = user_id);

-- Indexes
create index user_progress_lookup_idx
  on public.user_progress (user_id, course_id);

create index user_progress_step_idx
  on public.user_progress (course_id, step_id);
```

The unique constraint on (user_id, course_id, step_id) lets you use upsert with onConflict instead of insert — progress cannot be double-counted even if the same step is submitted twice.

## Build paths

### Lovable — fit 4/10, 4-6 hours

Best all-round path for web and mobile-web: Lovable auto-generates the Supabase schema, RLS policies, and React progress UI in a single prompt. The data layer is usually correct on the first pass because of Lovable's deep Supabase integration.

1. Create a new Lovable project and enable Lovable Cloud so the Supabase schema and auth are provisioned automatically
2. Paste the prompt below and let Agent Mode build the progress bar, milestone badges, step list, and streak counter
3. Open the preview and complete a step — confirm the bar fills and the row appears in the Supabase Table Editor
4. Enable pg_cron in Supabase Dashboard → Database → Extensions, then add the streak reset cron job from the prompt
5. Click Publish and test resumption by logging out, logging back in, and verifying the bar shows your previous progress

Starter prompt:

```
Build a user progress tracking feature for an online course app. Use Supabase for data. Create these tables if they don't exist: courses (id uuid pk, title text, total_steps int not null default 1), user_progress (id uuid pk, user_id uuid fk auth.users, course_id uuid fk courses, step_id text, status text default 'completed', completed_at timestamptz, unique(user_id, course_id, step_id)), user_achievements (user_id, badge_slug text, earned_at, unique(user_id, badge_slug)), user_streaks (user_id pk, current_streak int default 0, longest_streak int default 0, last_active_date date). Apply RLS: all tables restrict SELECT/INSERT/UPDATE to auth.uid() = user_id. UI: a course detail page showing a shadcn/ui Progress bar (0-100%) with the numeric percentage beside it, a list of step items with checkboxes — steps can be completed in any order. On checkbox click: upsert into user_progress with onConflict 'user_id,course_id,step_id' to avoid duplicates; recalculate percentage as (completed_count / total_steps) * 100, guard with Math.round((completed / (total || 1)) * 100). When percentage hits 100%, insert a row into user_achievements with badge_slug 'course-complete' (ON CONFLICT DO NOTHING) and show a confetti animation. Show a streak counter reading from user_streaks; update last_active_date and increment current_streak on each step completion. When the user returns, highlight the last incomplete step as 'Resume here'. Show an admin view at /admin/progress listing each user's completion rate across all courses.
```

Limitations:

- The streak reset cron job is described in the prompt but requires pg_cron to be manually enabled in Supabase Dashboard → Database → Extensions — the free tier supports it but it is off by default
- Badge pop-up animations (confetti, modal) often require a follow-up prompt specifying the exact animation library (canvas-confetti or Framer Motion)
- Mobile-native feel on touch devices requires additional Tailwind tweaks to the progress bar touch targets

### V0 — fit 3/10, 5-8 hours

Best when you need a polished admin dashboard showing all users' completion rates alongside the per-user progress bar — V0's shadcn/ui DataTable and Progress components are cleanest in this configuration.

1. Prompt V0 with the spec below to generate the course progress page and admin overview
2. Add Supabase environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
3. Run the SQL schema from this page in the Supabase SQL Editor to create the tables
4. Wrap any localStorage-based optimistic state in useEffect to prevent SSR hydration errors before publishing
5. Deploy to Vercel and verify the progress bar fills on step completion by checking the user_progress table in Supabase

Starter prompt:

```
Build a Next.js user progress tracking feature. Use Supabase with these tables: courses (id uuid pk, title text, total_steps int), user_progress (user_id uuid, course_id uuid, step_id text, status text, completed_at timestamptz, unique(user_id, course_id, step_id)), user_achievements (user_id, badge_slug, earned_at, unique per user+badge), user_streaks (user_id pk, current_streak int, longest_streak int, last_active_date date). Page /course/[id]: fetch the course and the user's completed steps server-side via a Supabase Server Client. Render a shadcn/ui Progress bar showing (completed_count / total_steps) * 100; calculate as Math.round((completed / (total || 1)) * 100) to guard NaN. Below it, list each step with a checkbox. On check: POST to /api/progress/complete with course_id and step_id; the API route upserts into user_progress with onConflict 'user_id,course_id,step_id' using the Supabase service client. Revalidate the page path. When progress reaches 100%, show a congratulations banner and upsert badge_slug 'course-complete' into user_achievements. Page /admin/progress: TanStack Table showing each user's email, course title, and completion percentage — query: SELECT u.email, c.title, COUNT(up.step_id) * 100.0 / c.total_steps as pct FROM users u JOIN user_progress up ON up.user_id = u.id JOIN courses c ON c.id = up.course_id GROUP BY u.email, c.title, c.total_steps. Guard admin route with middleware checking user role from Supabase user_metadata.
```

Limitations:

- V0 does not auto-provision Supabase — environment variables must be set manually in the Vars panel or Vercel Dashboard
- SSR optimistic state using localStorage causes hydration errors if not wrapped in useEffect with a client-side guard
- Supabase Realtime for live streak updates requires a custom useEffect subscription that V0 does not generate automatically

### Flutterflow — fit 5/10, 3-5 hours

Best path for mobile-native feel: Flutter's LinearProgressIndicator and CircularProgressIndicator animate smoothly, and FlutterFlow's Supabase Backend Query actions handle real-time updates via Realtime Stream with minimal custom code.

1. Create a FlutterFlow project connected to your Supabase project and import the schema tables
2. Design the course detail page: add a LinearProgressIndicator widget bound to a page state variable (completionPct); add a ListView of step items with Checkbox widgets
3. Wire each Checkbox to a Supabase upsert action (Action editor → Supabase Row → Upsert) writing to user_progress with the conflict resolution on user_id+course_id+step_id
4. After the upsert action, add a Backend Query that counts completed steps / total_steps and updates the completionPct page state variable — the progress bar animates automatically
5. Switch the course query to a Realtime Stream action (Backend Query → Set as Realtime Stream) so the bar updates if another device completes a step for the same user
6. In FlutterFlow Settings → Permissions, enable camera and photo library access descriptions if you plan to add avatars to the streak screen

Limitations:

- Code export requires FlutterFlow Pro ($70/mo) — the free plan runs in the FlutterFlow mobile preview app only
- Custom animations for the badge pop-up (confetti) require a Dart custom code block outside the visual builder
- Firestore is easier to connect than Supabase for first-time FlutterFlow users; follow the Supabase FlutterFlow docs carefully for the anon key setup

### Custom — fit 3/10, 1-2 weeks

Needed when progress logic is complex — branching curricula where step B unlocks only after A and C, weighted steps, or cohort-based progress that admins can reset or override.

1. Design a course_steps table with prerequisites JSONB to encode dependency graphs between steps
2. Build a progress resolver function that performs a topological sort to determine which steps are currently available to the user
3. Implement xAPI or SCORM 1.2 statement emission if the progress data must sync to an external LMS (Moodle, Docebo, Cornerstone)
4. Build an immutable audit log using Supabase Realtime + an append-only completions table for compliance requirements

Limitations:

- Full flexibility but no AI acceleration on the dependency resolution logic — a developer must hand-code the prerequisite graph traversal
- xAPI/SCORM integration adds 1-2 weeks of additional complexity beyond the core progress feature

## Gotchas

- **Progress resets to 0% on page reload** — The AI generates optimistic local state that increments the bar on click but is never actually persisted to Supabase — the INSERT into user_progress is either missing or fires without awaiting the result, so the database row is never created. On reload, the calculated percentage is 0 because no completed rows exist. Fix: Add an explicit upsert with onConflict: 'user_id,course_id,step_id' in the Supabase client call and verify the row appears in the Table Editor (Supabase Dashboard → Table Editor → user_progress) before testing the UI behaviour.
- **Streak counter never resets even after missed days** — The pg_cron job is described in the prompt and the AI writes the SQL function correctly, but Supabase pg_cron is not enabled by default on any tier — the extension must be turned on manually. Without it, current_streak just keeps accumulating regardless of activity. Fix: Enable pg_cron via Supabase Dashboard → Database → Extensions, search for 'pg_cron', and toggle it on. Then test the reset function by manually running it from the SQL Editor: SELECT cron.schedule('streak-reset', '1 0 * * *', 'SELECT reset_inactive_streaks()').
- **Progress bar shows NaN% for new courses** — The percentage calculation divides the completed step count by total_steps, but total_steps is NULL for newly created courses that haven't had the column value set. JavaScript's division returns NaN, which displays as an empty or broken bar. Fix: Add a NOT NULL DEFAULT 1 constraint on courses.total_steps (included in the SQL schema above) and guard the calculation: Math.round((completed / (total || 1)) * 100). The || 1 fallback prevents division by zero without hiding the real bug.
- **RLS silently blocks step completion writes** — Supabase RLS is enabled on user_progress but the INSERT policy generated by the AI is missing the WITH CHECK clause or uses the wrong condition. Anonymous or wrong-user writes fail silently — the Supabase client returns no error message, the row is never created, and progress appears to save but resets on reload. Fix: Open Dashboard → Authentication → Policies → user_progress and confirm the INSERT policy reads: WITH CHECK (auth.uid() = user_id). If the policy is missing the WITH CHECK, the USING clause alone does not protect inserts.
- **Mobile: progress bar does not update until pull-to-refresh** — In FlutterFlow, the Backend Query that fetches completion data is set to 'Query once' rather than subscribing to a Realtime Stream. When the user completes a step on another device — or when the upsert action fires faster than the UI re-renders — the progress bar shows stale data until the user manually refreshes. Fix: In FlutterFlow's Backend Query settings for the course detail page, switch the query mode to 'Realtime Stream' and filter by user_id = currentUserUid and course_id = the current course ID. The LinearProgressIndicator will animate to the new value automatically on each Realtime event.

## Best practices

- Use upsert with an explicit onConflict key (user_id, course_id, step_id) instead of insert — it prevents duplicate rows even when the user clicks a step checkbox rapidly or across two browser tabs
- Guard every percentage calculation against NaN: Math.round((completed / (total || 1)) * 100) — a missing total_steps value should never break the UI
- Show the last incomplete step as 'Resume here' on the course landing page — this single change measurably improves return-visit completion rates
- Store last_active_date in UTC and document that streak resets happen at midnight UTC — users in UTC-8 will lose their streak at 4 PM local time, so set expectations in the UI
- Award badges with ON CONFLICT DO NOTHING so re-running the badge evaluation function (e.g. on a page refresh) cannot double-award the same milestone
- Subscribe to Supabase Realtime on the user_achievements channel to show badge pop-ups without polling — polling every 5 seconds on 10,000 users costs real read quota
- Give admins a read-only view of every user's completion percentage — without it, you will not know when users get stuck at the same step and need content changes

## Frequently asked questions

### How do I save progress automatically without a Save button?

Wire each step checkbox or completion event directly to a Supabase upsert call. The upsert writes the row immediately on click — no Save button needed. Use upsert with onConflict: 'user_id,course_id,step_id' so rapid double-clicks don't create duplicate rows.

### Can I show progress across multiple courses on a single dashboard?

Yes — query user_progress grouped by course_id and join to courses.total_steps to calculate a percentage per course. Render one Progress bar per course in a grid. The SQL in the schema above and the V0 admin path show exactly this pattern.

### How do I prevent users from skipping required steps?

Add a prerequisite check in the step completion handler: before inserting into user_progress for step N, query whether step N-1 (or all prerequisites) are already completed. If not, return an error and show a tooltip explaining which step must come first. This logic needs to be explicit in your prompt — AI tools generate free-order completion by default.

### How do I award badges when milestones are reached?

After every step completion, calculate the new percentage server-side. If it crosses 25%, 50%, or 100%, upsert a row into user_achievements with the corresponding badge_slug and ON CONFLICT DO NOTHING to prevent duplicates. Subscribe to the user_achievements Realtime channel on the client to show a badge pop-up without polling.

### Will progress persist if a user switches devices?

Yes — because progress is stored in Supabase (server-side), not in localStorage. Any device that authenticates with the same user account will read the same completed steps and show the correct percentage. This is why the upsert must actually write to the database and not just update local React state.

### How do I let admins view or reset individual user progress?

Create an admin route (guarded by a user_roles check) that queries user_progress grouped by user and course. To reset a user's progress, delete their rows from user_progress and user_achievements for the target course — add a confirmation modal so resets are not accidental. Requires a service-role Supabase client in the API route to bypass RLS.

### How do I add a streak system that emails users when they are about to break it?

The streak reset pg_cron job runs at 00:01 UTC. Add a second job at 20:00 UTC that queries users where last_active_date = yesterday (streak is still alive but 4 hours from resetting) and sends each an email via Resend or your transactional email provider. Pass the user's current_streak count in the email subject for personalization.

---

Source: https://www.rapidevelopers.com/app-features/user-progress-tracking
© RapidDev — https://www.rapidevelopers.com/app-features/user-progress-tracking
