Build a Fitness App: Go API + Expo React Native
Build a fitness tracking app with a Go backend and Expo React Native frontend. You'll design a data model for workouts and exercises, generate API resources, build mobile screens with React Native, and create a workout logger you can use on your actual phone.
What We're Building
We're building a fitness tracking app — the kind of app people actually use every day. The Go API stores all the data (exercises, workouts, sets, reps, weight). The Expo app runs on your phone and lets you log workouts, browse exercises, and view your history.
The app has 4 core screens:
- • Exercise Library — browse all exercises grouped by muscle group (chest, back, legs, shoulders, arms)
- • Workout Logger — create a workout, add exercises, log sets/reps/weight in real-time
- • Workout History — see past workouts with total volume and duration
- • Dashboard — weekly summary, workout streak, total volume lifted
Challenge: Sketch Your Screens
On paper or a whiteboard, sketch 4 screens for a fitness app: Exercise Library, Workout Logger, Workout History, and Dashboard. For each screen, write down what data it needs to display. What actions can the user take on each screen?
Scaffold the Project
Grit's --mobile flag scaffolds a monorepo with a Go API and an Expo React Native app:
grit new fitness --mobileThis creates a monorepo with three parts:
fitness/
├── docker-compose.yml # PostgreSQL, Redis, MinIO, Mailhog
├── turbo.json # Monorepo task runner
├── pnpm-workspace.yaml # Workspace definition
├── apps/
│ ├── api/ # Go backend (Gin + GORM)
│ │ ├── cmd/server/main.go
│ │ └── internal/ # models, handlers, services, middleware
│ └── expo/ # Expo React Native app
│ ├── app/ # File-based routing (Expo Router)
│ ├── components/ # Reusable React Native components
│ ├── hooks/ # Custom hooks (useAuth, useQuery)
│ ├── lib/ # API client, storage, utils
│ ├── app.json # Expo config
│ └── package.json
└── packages/
└── shared/ # Shared types and validation (Zod)The key difference from a web project: instead of apps/web (Next.js), you get apps/expo (Expo React Native). The Go API is identical — the mobile app consumes the same REST endpoints.
packages/shared directory contains TypeScript types and Zod schemas shared between the API types and the Expo app. When you generate a resource, Grit updates the shared types so both the API and the mobile app stay in sync.Challenge: Scaffold and Explore
Run grit new fitness --mobile. Open the project in your editor. Compare the folder structure to a web project (grit new myapp). What's the same? What's different? How many files are in apps/expo/?
Design the Data Model
Before writing code, let's design the data model. A fitness app needs three core entities:
Our three resources:
| Resource | Fields | Relationships |
|---|---|---|
| Exercise | name, muscle_group, description (optional) | Has many WorkoutExercises |
| Workout | name, date, duration (minutes), notes (optional) | Has many WorkoutExercises |
| WorkoutExercise | sets, reps, weight (kg/lbs) | Belongs to Workout, belongs to Exercise |
The relationship is: a Workout contains many WorkoutExercises, and each WorkoutExercise references an Exercise. This is a classic many-to-many relationship through a join table (WorkoutExercise).
# Exercise — the library of all possible exercises
grit generate resource Exercise --fields "name:string,muscle_group:string,description:text:optional"
# Workout — a single training session
grit generate resource Workout --fields "name:string,date:date,duration:int,notes:text:optional"
# WorkoutExercise — a specific exercise performed in a workout
grit generate resource WorkoutExercise --fields "sets:int,reps:int,weight:float,workout_id:belongs_to:Workout,exercise_id:belongs_to:Exercise"After generating, restart the API. GORM will auto-migrate the new tables.
Challenge: Generate the Resources
Generate all 3 resources using the commands above. Restart the API. Open GORM Studio at /studio and verify that the exercises, workouts, and workout_exercises tables exist. How many columns does the workout_exercises table have?
Seed Sample Data
An empty exercise library is useless. Let's populate it with real exercises. You can do this through GORM Studio's inline editing or through the API.
Here are the exercises to create, organized by muscle group:
# Chest
POST /api/exercises {"name": "Bench Press", "muscle_group": "Chest", "description": "Flat barbell bench press"}
POST /api/exercises {"name": "Incline Dumbbell Press", "muscle_group": "Chest", "description": "Incline bench with dumbbells"}
POST /api/exercises {"name": "Cable Fly", "muscle_group": "Chest", "description": "Cable crossover fly movement"}
# Back
POST /api/exercises {"name": "Deadlift", "muscle_group": "Back", "description": "Conventional barbell deadlift"}
POST /api/exercises {"name": "Pull-up", "muscle_group": "Back", "description": "Bodyweight pull-up (add weight as needed)"}
POST /api/exercises {"name": "Barbell Row", "muscle_group": "Back", "description": "Bent-over barbell row"}
# Legs
POST /api/exercises {"name": "Squat", "muscle_group": "Legs", "description": "Barbell back squat"}
POST /api/exercises {"name": "Romanian Deadlift", "muscle_group": "Legs", "description": "Stiff-leg deadlift for hamstrings"}
POST /api/exercises {"name": "Leg Press", "muscle_group": "Legs", "description": "Machine leg press"}
# Shoulders
POST /api/exercises {"name": "Overhead Press", "muscle_group": "Shoulders", "description": "Standing barbell press"}/docs. Click the POST /api/exercises endpoint, fill in the JSON body, and hit Execute. It's faster than curl for manual data entry.Challenge: Seed 10 Exercises
Create at least 10 exercises across 4 different muscle groups (Chest, Back, Legs, Shoulders). Use the API docs or GORM Studio. After creating them, call GET /api/exercises and verify all 10 are returned. Try filtering with ?muscle_group=Chest — does it work?
Start API + Expo
With the API running and seed data loaded, it's time to start the Expo mobile app. You'll need two terminals — one for the API and one for Expo.
cd fitness
docker compose up -d
cd apps/api && go run cmd/server/main.gocd fitness/apps/expo
pnpm install
pnpm startExpo will show a QR code in the terminal. To open the app on your phone:
- • iPhone — install Expo Go from the App Store, scan the QR code with your camera
- • Android — install Expo Go from Google Play, scan the QR code from the Expo Go app
- • Emulator — press
ifor iOS Simulator orafor Android Emulator
pnpm start --tunnel to route through Expo's servers instead.Challenge: Run the App
Start both the API and Expo. Open the app on your phone or emulator. You should see the default Grit mobile app with login/register screens. Register a user, log in, and confirm you see the main app screen. Is the API responding to requests from the mobile app?
Build the Exercise List Screen
The exercise list is the first screen users see. It fetches exercises from the API and displays them in a scrollable list, grouped by muscle group.
Here's how to fetch and display exercises:
import { View, Text, FlatList, TouchableOpacity } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
interface Exercise {
id: number
name: string
muscle_group: string
description?: string
}
export default function ExercisesScreen() {
const { data, isLoading } = useQuery({
queryKey: ['exercises'],
queryFn: () => api.get('/api/exercises'),
})
const exercises: Exercise[] = data?.data || []
// Group by muscle_group
const grouped = exercises.reduce((acc, ex) => {
if (!acc[ex.muscle_group]) acc[ex.muscle_group] = []
acc[ex.muscle_group].push(ex)
return acc
}, {} as Record<string, Exercise[]>)
if (isLoading) return <Text>Loading...</Text>
return (
<FlatList
data={Object.entries(grouped)}
keyExtractor={([group]) => group}
renderItem={({ item: [group, items] }) => (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold', marginBottom: 8 }}>
{group}
</Text>
{items.map((ex) => (
<TouchableOpacity key={ex.id} style={{ padding: 12, marginBottom: 4 }}>
<Text style={{ fontSize: 16 }}>{ex.name}</Text>
{ex.description && (
<Text style={{ color: '#666', fontSize: 13 }}>{ex.description}</Text>
)}
</TouchableOpacity>
))}
</View>
)}
/>
)
}The pattern is straightforward: useQuery fetches the data, we group it by muscle group using reduce, and FlatList renders each group with its exercises.
Challenge: Build the Exercise List
Create the exercise list screen. Display exercises grouped by muscle group. Each exercise should show its name and description. Verify that all the exercises you seeded appear. How many muscle groups are displayed?
Build the Workout Logger
The workout logger is the core feature of the app. Users create a workout, add exercises to it, and log sets with reps and weight for each exercise. This involves creating a Workout record and then creating WorkoutExercise records linked to it.
The flow is:
1. User taps "Start Workout"
→ POST /api/workouts { name: "Push Day", date: "2026-03-27" }
→ Returns workout with ID
2. User picks exercises from the library
→ Each selection adds to a local list
3. For each exercise, user logs sets:
→ POST /api/workout-exercises {
workout_id: 1,
exercise_id: 3,
sets: 4,
reps: 10,
weight: 80.0
}
4. User taps "Finish Workout"
→ PATCH /api/workouts/1 { duration: 55 }
→ Duration calculated from start to finishThe workout form combines API calls with local state. The workout itself is created on the server first (so we get an ID), then each exercise entry is posted as the user logs it:
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
interface SetEntry {
exercise_id: number
exercise_name: string
sets: number
reps: number
weight: number
}
export default function WorkoutLoggerScreen() {
const [workoutId, setWorkoutId] = useState<number | null>(null)
const [entries, setEntries] = useState<SetEntry[]>([])
const [startTime] = useState(new Date())
const queryClient = useQueryClient()
const createWorkout = useMutation({
mutationFn: (name: string) =>
api.post('/api/workouts', {
name,
date: new Date().toISOString().split('T')[0],
duration: 0,
}),
onSuccess: (data) => setWorkoutId(data.data.id),
})
const logExercise = useMutation({
mutationFn: (entry: SetEntry) =>
api.post('/api/workout-exercises', {
workout_id: workoutId,
exercise_id: entry.exercise_id,
sets: entry.sets,
reps: entry.reps,
weight: entry.weight,
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['workouts'] }),
})
// ... render workout form with exercise picker and set inputs
}useMutation from TanStack Query for POST/PATCH/DELETE operations. It handles loading states, error states, and cache invalidation — just like useQuery does for GET requests.Challenge: Log a Complete Workout
Build the workout logger screen. Create a workout called "Push Day." Add 4 exercises (e.g., Bench Press, Incline Dumbbell Press, Cable Fly, Overhead Press). For each exercise, log sets, reps, and weight. When you're done, verify the data exists in the API by calling GET /api/workout-exercises?workout_id=1.
Build the History Screen
The history screen shows all past workouts with summary data: name, date, duration, and total volume. Volume is the key metric in fitness tracking — it's the total weight lifted in a workout.
import { View, Text, FlatList } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
interface Workout {
id: number
name: string
date: string
duration: number
workout_exercises: {
sets: number
reps: number
weight: number
exercise: { name: string }
}[]
}
function calculateVolume(workout: Workout): number {
return workout.workout_exercises.reduce(
(total, we) => total + we.sets * we.reps * we.weight,
0
)
}
export default function HistoryScreen() {
const { data } = useQuery({
queryKey: ['workouts'],
queryFn: () => api.get('/api/workouts?sort=date&order=desc'),
})
const workouts: Workout[] = data?.data || []
return (
<FlatList
data={workouts}
keyExtractor={(w) => String(w.id)}
renderItem={({ item: workout }) => (
<View style={{ padding: 16, borderBottomWidth: 1, borderColor: '#222' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{workout.name}</Text>
<Text style={{ color: '#666' }}>{workout.date}</Text>
<View style={{ flexDirection: 'row', gap: 16, marginTop: 8 }}>
<Text style={{ color: '#999' }}>{workout.duration} min</Text>
<Text style={{ color: '#6c5ce7' }}>
{calculateVolume(workout).toLocaleString()} kg volume
</Text>
</View>
<Text style={{ color: '#888', marginTop: 4 }}>
{workout.workout_exercises.length} exercises
</Text>
</View>
)}
/>
)
}The calculateVolume function sums sets * reps * weight for every exercise in the workout. This gives users a clear, numeric measure of how hard they trained.
Challenge: Build the History Screen
Create the history screen. Log at least 5 workouts over a "week" (you can backdate them by setting different dates in the API). Display them in reverse chronological order (newest first). Each entry should show: workout name, date, duration, number of exercises, and total volume. Which workout had the highest volume?
Add Pull-to-Refresh
Mobile users expect to pull down on a list to refresh it. This is a standard mobile pattern that React Native supports natively through the RefreshControl component.
import { FlatList, RefreshControl } from 'react-native'
import { useQuery } from '@tanstack/react-query'
export default function ExercisesScreen() {
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['exercises'],
queryFn: () => api.get('/api/exercises'),
})
return (
<FlatList
data={data?.data || []}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
// ... render exercise
)}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#6c5ce7"
/>
}
/>
)
}TanStack Query makes this trivial. The refetch function triggers a fresh API call, and isRefetching tracks whether the refetch is in progress. Wire these into RefreshControl and you're done.
Challenge: Add Pull-to-Refresh
Add RefreshControl to both the Exercise List and Workout History screens. Pull down on each list — does the spinner appear? Does the data reload? Test it by adding a new exercise through the API docs and pulling to refresh on the exercise list. Does the new exercise appear?
Summary
Here's everything you learned in this course:
- Grit --mobile scaffolds a monorepo with Go API + Expo React Native
- Data modeling with three resources: Exercise, Workout, WorkoutExercise
- belongs_to relationships connect WorkoutExercises to Workouts and Exercises
- Seed data provides an exercise library users can pick from
- FlatList renders performant scrollable lists in React Native
- useQuery (TanStack Query) handles fetching, caching, and loading states
- useMutation handles POST/PATCH/DELETE with cache invalidation
- Volume (sets x reps x weight) is the key metric for tracking progress
- RefreshControl adds pull-to-refresh to any FlatList
- Expo Go lets you test on a physical device without native toolchains
Challenge: Personal Records (Part 1)
Design a Personal Records (PR) feature. For each exercise, track the highest weight the user has ever lifted. How would you calculate this? You don't need a new database table — you can derive PRs from existing WorkoutExercise data. Write the API query that finds the max weight for each exercise.
Challenge: Personal Records (Part 2)
Build a PR display screen. For each exercise the user has performed, show the exercise name, the PR weight, and the date it was set. Sort by most recent PR first.
Challenge: Personal Records (Part 3)
Add a "New PR!" badge to the workout logger. When a user logs a weight that exceeds their previous best for that exercise, display a visual indicator (a badge, animation, or color change). Compare the current weight against the max weight from all previous WorkoutExercise records for that exercise. This is the final touch — congratulate users when they beat their records.
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.