Skip to main content
RapidDev - Software Development Agency

How to Build a Fitness Tracking with Lovable

Build a fitness tracking app in Lovable where users log workouts with dynamic sets and reps inputs, track body metrics over time, visualize progress with Recharts line charts, and automatically detect personal records via Postgres aggregate functions — complete with reusable workout templates and a weekly training calendar.

What you'll build

  • Workout logging form with dynamic exercise rows that add/remove sets and reps fields
  • Personal records auto-detected via a Postgres function querying MAX weight per exercise
  • Body metrics tracker (weight, body fat, measurements) with time-series line chart
  • Progress charts per exercise showing max weight and total volume over time
  • Reusable workout templates that pre-fill the logging form
  • Weekly training calendar heatmap showing workout frequency
  • Dashboard with this week's stats: workouts completed, total volume, and PRs set
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–2.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a fitness tracking app in Lovable where users log workouts with dynamic sets and reps inputs, track body metrics over time, visualize progress with Recharts line charts, and automatically detect personal records via Postgres aggregate functions — complete with reusable workout templates and a weekly training calendar.

What you're building

Fitness tracking data has a nested structure: a workout session contains multiple exercises, each exercise contains multiple sets, each set has reps and weight. The schema models this with three linked tables: workouts → workout_exercises → workout_sets. This separation makes querying clean — you can get all sets for a specific exercise across all workouts without loading unrelated data.

Personal records are detected server-side. A Postgres function get_personal_records(p_user_id) queries each exercise a user has logged and returns the maximum weight achieved. This runs as a single efficient query rather than checking records client-side. The function is called when the user logs a new set, and if the weight exceeds the previous record, a PR flag is shown in the UI.

The dynamic form is the most complex UI piece. Each exercise row has a 'Add Set' button that appends a new set object to a useFieldArray from react-hook-form. Sets can be removed individually. Submitting the form inserts one workouts row, N workout_exercises rows, and all set rows in a single operation.

Final result

A personal fitness tracker where workouts are logged with full set/rep detail, PRs are celebrated automatically, and progress is visualized with clean Recharts charts.

Tech stack

LovableFrontend app
SupabaseDatabase with RLS
shadcn/uiUI components
RechartsProgress and metrics charts
react-hook-form + zodDynamic workout log form
Supabase AuthPer-user data isolation

Prerequisites

  • Lovable account (free tier sufficient for this build)
  • Supabase project with SUPABASE_URL and SUPABASE_ANON_KEY available
  • A list of exercises to seed the exercises reference table (e.g. Bench Press, Squat, Deadlift)
  • Basic understanding of sets, reps, and progressive overload concepts
  • Familiarity with how fitness apps like Strong or Hevy work (for UX reference)

Build steps

1

Create the fitness tracking schema

Ask Lovable to create all four tables and the personal records function. The schema is the foundation for all other features.

prompt.txt
1Create a fitness tracking schema in Supabase.
2
3Tables:
4- exercises: id, name (text unique), muscle_group ('chest' | 'back' | 'legs' | 'shoulders' | 'arms' | 'core' | 'cardio'), is_custom (bool default false), created_by (references auth.users, nullable for global exercises)
5- workouts: id, user_id (references auth.users), name, notes, started_at (timestamptz), finished_at (timestamptz), created_at
6- workout_exercises: id, workout_id (references workouts), exercise_id (references exercises), order_index (int), notes (text)
7- workout_sets: id, workout_exercise_id (references workout_exercises), set_number (int), weight_kg (decimal), reps (int), is_warmup (bool default false), rpe (int, 1-10, nullable), created_at
8- body_metrics: id, user_id (references auth.users), date (date), weight_kg (decimal), body_fat_pct (decimal, nullable), notes (text), created_at
9- workout_templates: id, user_id (references auth.users), name, template_data (jsonb), created_at
10
11Create a Postgres function get_personal_records(p_user_id uuid) that returns a table of (exercise_id uuid, exercise_name text, max_weight_kg decimal, achieved_at timestamptz). Query workout_sets joined through workout_exercises workouts filtered by user_id, group by exercise, return MAX(weight_kg) and the timestamp of that max.
12
13RLS: all tables scope to auth.uid() = user_id. exercises are globally readable (SELECT for all authenticated users), INSERT only for the creating user.

Pro tip: Add a UNIQUE constraint on body_metrics(user_id, date) to prevent duplicate entries for the same day. This also enables INSERT ... ON CONFLICT (user_id, date) DO UPDATE for easy upsert when the user edits today's metrics.

Expected result: All six tables are created. The get_personal_records function returns rows for each exercise logged by the user. TypeScript types are generated. Supabase Auth is set up.

2

Build the dynamic workout logging form

The workout log form is the core of the app. It uses useFieldArray from react-hook-form to manage the nested set/rep structure dynamically. Ask Lovable to build it from this specification.

prompt.txt
1Build a workout logging form at src/pages/LogWorkout.tsx.
2
3Form structure using react-hook-form with zod validation:
4- Workout name Input (text, required)
5- Start time (auto-filled, editable DateTimePicker)
6- Dynamic exercises array (useFieldArray):
7 - Each exercise row has:
8 - Exercise Combobox (searchable Select from exercises table, filtered by muscle group tabs)
9 - Notes Input (optional)
10 - Dynamic sets array nested inside each exercise (useFieldArray):
11 - Set number (auto-incremented, read-only)
12 - Weight Input (number, kg, required)
13 - Reps Input (number, required)
14 - RPE Select (optional, 6-10)
15 - Warmup Checkbox
16 - Delete set Button (X icon)
17 - 'Add Set' Button appending a new set row with defaults from the previous set
18 - 'Remove Exercise' Button
19- 'Add Exercise' Button appending a new exercise row
20- 'Save Workout' Button at the bottom
21
22On submit:
231. INSERT into workouts, get the workout id
242. For each exercise, INSERT into workout_exercises with order_index
253. For each exercise's sets, INSERT all workout_sets rows in a single batch call
264. After saving, call get_personal_records and check if any new set exceeded the previous PR if so, show a confetti animation and Toast with the PR details
275. Navigate to /workouts/{id} after successful save

Expected result: The form renders with dynamic add/remove for both exercises and sets. Submitting creates all rows in the correct tables. PR detection fires after save. Navigation to the workout detail page works.

3

Build the progress charts

Ask Lovable to build the exercise progress page where users see their strength progression over time with Recharts line charts.

prompt.txt
1Build a progress page at src/pages/Progress.tsx.
2
3Requirements:
4- Exercise selector: a Combobox at the top that lists all exercises the current user has logged, grouped by muscle group
5- When an exercise is selected, fetch all workout_sets for that exercise joined through workout_exercises workouts WHERE user_id = auth.uid(), ordered by workouts.started_at
6- Render two Recharts charts side by side (stacked on mobile):
7 - Chart 1: LineChart of max weight per session (X = date, Y = weight_kg). Show a gold star data point on the session where the all-time PR was achieved.
8 - Chart 2: LineChart of total volume per session (X = date, Y = SUM(weight_kg * reps) for all sets in that session)
9- Below charts: a DataTable of all logged sets for that exercise. Columns: Date, Workout Name, Sets x Reps @ Weight (formatted as '3 x 8 @ 80kg'), Volume, PR badge if it was a PR session.
10- Add a date range filter (last 30 days / 90 days / all time) that filters both charts and table
11- Show the current PR as a stat Card above the charts: 'All-time PR: 100kg on Jan 15'

Expected result: Selecting an exercise shows the two progress charts and history table. The PR badge appears on the correct row. Date range filter updates both charts. The PR stat card shows the correct value.

4

Build the body metrics tracker

Ask Lovable to add the body metrics page for weight and body composition tracking with a trend line chart.

prompt.txt
1Build a body metrics page at src/pages/BodyMetrics.tsx.
2
3Requirements:
4- 'Log Today's Metrics' Card at the top with a simple form:
5 - Date (DatePicker, default today)
6 - Weight Input (decimal, kg or lbs with unit toggle stored in user preferences)
7 - Body Fat % Input (decimal, optional)
8 - Notes Textarea (optional)
9 - Save Button that does supabase.from('body_metrics').upsert() using (user_id, date) conflict target
10- Below the form, a Recharts ComposedChart:
11 - Primary line: weight over time (last 90 days)
12 - Secondary line (if data exists): body_fat_pct as a dashed line on a secondary Y-axis
13 - Show a 7-day rolling average line in a lighter color
14 - Tooltip showing both values on hover
15- Stats row: Starting Weight, Current Weight, Change (formatted with up/down arrow), Lowest Weight
16- History DataTable: columns Date, Weight, Body Fat %, Notes. Allow row click to edit.

Expected result: Logging metrics upserts correctly — logging twice on the same date updates rather than duplicates. The chart shows weight trend with rolling average. The stats cards update dynamically.

5

Add workout templates and calendar heatmap

Templates let users save and reuse workout structures. The calendar heatmap shows training frequency at a glance. Ask Lovable to add both to the app.

prompt.txt
1Add two features:
2
31. Workout templates:
4 - On the LogWorkout page, add a 'Load Template' Button at the top that opens a Sheet
5 - The Sheet shows a list of saved templates (Cards with template name and exercise count)
6 - Clicking a template populates the form with all exercises and default sets from template_data jsonb
7 - After logging a workout, add a 'Save as Template' option in a menu. It stores the exercise structure (without weights/reps) as jsonb in workout_templates
8
92. Training calendar heatmap at src/pages/Calendar.tsx:
10 - Show the last 52 weeks as a grid of day squares (like GitHub contribution graph)
11 - Each day square is colored by workout count: 0=gray, 1=light green, 2=medium green, 3+=dark green
12 - Hovering a day shows a Tooltip with the workout name(s) that day
13 - Fetch all workouts for the user, group by date(started_at), count per day
14 - Below the heatmap: streak stats current streak (consecutive days with workouts), longest streak, total workouts this year

Expected result: Loading a template fills the log form with the template's exercise structure. The calendar heatmap renders 52 weeks with correct coloring. Streak stats calculate correctly from the workouts data.

Complete code

src/components/WorkoutForm.tsx
1import { useForm, useFieldArray, Controller } from 'react-hook-form'
2import { zodResolver } from '@hookform/resolvers/zod'
3import { z } from 'zod'
4import { Button } from '@/components/ui/button'
5import { Input } from '@/components/ui/input'
6import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
7import { Trash2, Plus } from 'lucide-react'
8
9const setSchema = z.object({
10 weight_kg: z.coerce.number().min(0),
11 reps: z.coerce.number().min(1).max(999),
12 is_warmup: z.boolean().default(false),
13})
14
15const exerciseSchema = z.object({
16 exercise_id: z.string().uuid(),
17 sets: z.array(setSchema).min(1),
18})
19
20const workoutSchema = z.object({
21 name: z.string().min(1, 'Workout name required'),
22 exercises: z.array(exerciseSchema).min(1, 'Add at least one exercise'),
23})
24
25type WorkoutFormValues = z.infer<typeof workoutSchema>
26
27export function WorkoutForm({ onSubmit }: { onSubmit: (data: WorkoutFormValues) => Promise<void> }) {
28 const form = useForm<WorkoutFormValues>({
29 resolver: zodResolver(workoutSchema),
30 defaultValues: { name: '', exercises: [] },
31 })
32
33 const { fields: exerciseFields, append: addExercise, remove: removeExercise } = useFieldArray({
34 control: form.control,
35 name: 'exercises',
36 })
37
38 return (
39 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
40 <Input placeholder="Workout name" {...form.register('name')} />
41 {exerciseFields.map((exercise, exIdx) => (
42 <ExerciseRow
43 key={exercise.id}
44 exIdx={exIdx}
45 control={form.control}
46 register={form.register}
47 onRemove={() => removeExercise(exIdx)}
48 />
49 ))}
50 <Button type="button" variant="outline" onClick={() => addExercise({ exercise_id: '', sets: [{ weight_kg: 0, reps: 5, is_warmup: false }] })}>
51 <Plus className="mr-2 h-4 w-4" /> Add Exercise
52 </Button>
53 <Button type="submit" className="w-full">Save Workout</Button>
54 </form>
55 )
56}
57
58function ExerciseRow({ exIdx, control, register, onRemove }: any) {
59 const { fields: setFields, append: addSet, remove: removeSet } = useFieldArray({
60 control,
61 name: `exercises.${exIdx}.sets`,
62 })
63 return (
64 <Card>
65 <CardHeader className="flex flex-row items-center justify-between pb-2">
66 <CardTitle className="text-sm">Exercise {exIdx + 1}</CardTitle>
67 <Button type="button" variant="ghost" size="icon" onClick={onRemove}><Trash2 className="h-4 w-4" /></Button>
68 </CardHeader>
69 <CardContent className="space-y-2">
70 {setFields.map((set, setIdx) => (
71 <div key={set.id} className="flex items-center gap-2">
72 <span className="text-sm text-muted-foreground w-6">{setIdx + 1}</span>
73 <Input type="number" placeholder="kg" className="w-20" {...register(`exercises.${exIdx}.sets.${setIdx}.weight_kg`)} />
74 <Input type="number" placeholder="reps" className="w-20" {...register(`exercises.${exIdx}.sets.${setIdx}.reps`)} />
75 <Button type="button" variant="ghost" size="icon" onClick={() => removeSet(setIdx)}><Trash2 className="h-3 w-3" /></Button>
76 </div>
77 ))}
78 <Button type="button" variant="ghost" size="sm" onClick={() => addSet({ weight_kg: setFields[setFields.length - 1]?.weight_kg ?? 0, reps: 5, is_warmup: false })}>
79 <Plus className="mr-1 h-3 w-3" /> Add Set
80 </Button>
81 </CardContent>
82 </Card>
83 )
84}

Customization ideas

Rest timer between sets

Add a countdown timer that starts automatically when a set is saved. Display it as a floating badge in the bottom corner. Use the Web Audio API to play a beep when rest time ends. Store default rest time per exercise in the workout_exercises table.

One-rep max calculator

Add a 1RM estimator card to the progress page. Use the Epley formula: weight * (1 + reps / 30). Show estimated 1RM history alongside actual max weight. This is useful for planning progression in powerlifting-style programs.

Training program support

Add programs and program_weeks tables. A program is a structured plan (e.g. 5x5, PPL) with prescribed workouts for each day. The calendar view shows upcoming prescribed workouts. When logging, users can log against a program day, and the app tracks adherence percentage.

Photo progress journal

Add progress_photos table with user_id, date, storage_path, and notes. Add a photo upload section to the body metrics page. Display thumbnails in a timeline view sorted by date. Store photos in a private Supabase Storage bucket with RLS restricting access to the owner.

Share workout summary

Add a share button on the post-workout summary page that generates a formatted image of the workout stats (exercises, total volume, PRs set) using HTML Canvas. The user can download or share directly to social media. This drives organic user acquisition.

Common pitfalls

Pitfall: Storing all set data in a single JSON column instead of normalized tables

How to avoid: Use the three-table normalized structure: workouts → workout_exercises → workout_sets. The PR query becomes a simple SELECT MAX(weight_kg) JOIN — fast with proper indexes.

Pitfall: Fetching all workout history for chart rendering

How to avoid: Query only the data needed for each chart: for the progress chart, fetch only (started_at, MAX(weight_kg), SUM(weight_kg*reps)) per session for the selected exercise in the selected date range. Group the aggregation in the Supabase query, not in JavaScript.

Pitfall: Not validating the nested form before submission

How to avoid: Use zod schema validation with react-hook-form. The workoutSchema requires exercise_id to be a valid UUID, weight_kg to be a positive number, and reps to be at least 1. Display per-field errors inline rather than a generic form-level error.

Pitfall: Displaying weight in kg without a unit preference setting

How to avoid: Store all weights internally in kg (the SI unit). Add a unit_preference column to the user's profile (default 'kg'). Convert for display: lbs = kg * 2.20462. All inputs accept the user's preferred unit and convert before saving.

Best practices

  • Store all weights in kilograms in the database regardless of user preference. Apply unit conversion only at the display and input layer.
  • Index workout_sets on (workout_exercise_id) and workout_exercises on (workout_id) to keep the nested data loading fast.
  • Use useFieldArray from react-hook-form for dynamic form arrays — do not try to manage nested form state manually with useState.
  • The get_personal_records function should be called once per page load and cached in component state, not re-called after every set save. Call it on the progress page load and after completing a full workout.
  • Set minimum reps to 1 and minimum weight to 0 (bodyweight exercises) in your zod schema. Negative values are invalid data that will corrupt PR calculations.
  • Show the total volume (sum of weight_kg times reps for all sets) for each workout in the history list. Volume is the primary driver of hypertrophy and users appreciate seeing it alongside max weight.
  • Implement optimistic updates for set logging: add the set to local state immediately when the user clicks 'Add Set', then sync to Supabase in the background. This makes the form feel instant even on slow connections.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a fitness tracker in Supabase with tables: workouts, workout_exercises, workout_sets (weight_kg, reps). I need a Postgres function get_personal_records(p_user_id uuid) that returns each exercise the user has ever logged along with their maximum weight_kg and the date it was achieved. Write the full SQL function including the JOIN chain from workout_sets through workout_exercises to workouts, filtered by workouts.user_id.

Lovable Prompt

Add a workout history page at /history. Fetch all workouts for the current user ordered by started_at DESC. Display as a list of accordion items. Each accordion header shows: workout date (formatted as 'Monday, Jan 15'), workout name, duration (finished_at minus started_at), total volume (sum of all weight*reps). Expanding an accordion item shows each exercise with its sets as a compact table: set number, weight, reps, volume. Add infinite scroll: load 20 workouts at a time, load more as the user scrolls.

Build Prompt

In Supabase, write a SQL query I can use to build a weekly volume chart. It should return (week_start date, exercise_id, exercise_name, total_volume decimal) for a specific user, where total_volume is SUM(weight_kg * reps) grouped by ISO week and exercise. Filter to the last 12 weeks. Order by week_start ASC. I'll use this as a Supabase RPC function called from Recharts in React.

Frequently asked questions

How does the app detect a new personal record?

After saving a workout, call the get_personal_records Postgres function via supabase.rpc('get_personal_records', { p_user_id: userId }). Compare the returned max weights with the weights just logged. If any new set weight exceeds the stored PR for that exercise, display the PR notification. The function uses MAX(weight_kg) grouped by exercise across all historical sets.

Can I track cardio sessions like running alongside lifting?

Add a duration_seconds and distance_km column to workout_sets (nullable). For cardio exercises (identified by muscle_group='cardio'), the form shows duration and distance inputs instead of weight and reps. The exercise schema handles both. Progress charts for cardio show pace (distance/time) instead of max weight.

How do I handle exercises where bodyweight is the load?

Set weight_kg = 0 for pure bodyweight exercises (pull-ups, push-ups). Add a bodyweight_addition_kg field that stores added weight (e.g. +10kg with a weight vest). The effective weight for PR calculation is user's bodyweight + bodyweight_addition_kg. Store the user's bodyweight from body_metrics for this calculation.

What is RPE and should I make it required?

RPE (Rate of Perceived Exertion) is a 1-10 scale where 10 is maximum effort. It is optional — many users prefer to track only weight and reps. Make it optional in the form with a tooltip explaining what it means. Advanced users who practice RPE-based programming will appreciate having it. Never make it required as it adds friction for beginners.

How many workouts can Supabase store before performance degrades?

With proper indexing, Supabase handles millions of rows comfortably. A dedicated user logging 5 workouts per week with 20 sets each generates roughly 5,200 workout_sets rows per year. Even at 10 years of data, that is 52,000 rows — trivial for PostgreSQL with indexes on user_id and workout_id.

Can multiple users share the same exercises database?

Yes. Seed a set of global exercises (is_custom = false, created_by = null) that all users can see. These are exercises like Bench Press, Squat, and Deadlift. Users can create their own custom exercises (is_custom = true, created_by = auth.uid()) visible only to them. The exercises Combobox shows global exercises first, then the user's custom exercises.

How do I export a user's full workout history?

Create an export function that queries all workouts with their nested exercises and sets, flattens the data to a CSV format (one row per set: date, workout name, exercise name, set number, weight, reps), and triggers a browser download. For large histories, paginate the query in 500-row batches and combine them client-side before creating the CSV blob.

Is there help building a fitness app with social features or trainer management?

RapidDev builds Lovable apps with social feeds, coach-client relationships, and subscription billing. Reach out if your fitness app needs features beyond personal tracking.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help building your app?

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.