Build a Fitness App with React Native
The same SDK, on a phone , sign users up to an auto-built program, browse workouts and exercises, and play them in portrait.
The SDK is just fetch under the hood, so it runs unchanged in React Native , no native modules, no rewrite. This guide builds a real app: a signup that auto-recommends a training program, a program screen, workout and exercise libraries, and a Reels-style portrait player.
It runs on iOS, Android, and the web from one codebase.
4 chapters · ~105 min · Expo · React Native · TypeScript
Chapter 1 · Project: Mobile Fitness (Expo)
Install & set up Expo + the SDK
Scaffold, wire the key the mobile way, and make your first call.
Scaffold and install
Create an Expo app and add the SDK plus expo-constants (to read config). Nothing native is required , the SDK uses the fetch that React Native already provides.
npx create-expo-app ymove-mobile
cd ymove-mobile
npm install ymove-exercise-api expo-constantsThe API key, the mobile way
There is no server in a mobile app, so the client reads the key from Expo config (app.json extra.ymoveApiKey) or an EXPO_PUBLIC_ env var.
Production caveat worth saying out loud: a shipped binary embeds whatever key it ships with, and a determined user can extract it. So in a real app you do not ship the ymove key at all , you log the user in, the app calls your backend with that user session/JWT, and your backend makes the ymove calls with the key kept server-side. That is the same server-side pattern the Next.js guides use (the key never leaves the server); reading a key from Expo config here is only to keep the example self-contained.
import { YMoveClient } from 'ymove-exercise-api';
import Constants from 'expo-constants';
const extra = (Constants.expoConfig?.extra ?? {}) as { ymoveApiKey?: string };
const apiKey = extra.ymoveApiKey?.trim() || process.env.EXPO_PUBLIC_YMOVE_API_KEY;
if (!apiKey) throw new Error('Missing ymove API key , set extra.ymoveApiKey in app.json');
export const ymove = new YMoveClient(apiKey);First call + run it
Call ymove.workouts.generate on mount, exactly like the web, with loading/error state and an unmount guard. Then start Expo and pick a platform.
useEffect(() => {
let off = false;
ymove.workouts.generate({ muscleGroup: 'chest', difficulty: 'intermediate', exerciseCount: 5 })
.then((w) => !off && setWorkout(w))
.catch((e) => !off && setError(e?.message ?? 'Failed to load workout'))
.finally(() => !off && setLoading(false));
return () => { off = true; };
}, []);Expo Go vs a native build (and do you need a Mac?)
Expo Go is the fast lane: install the Expo Go app, scan the QR, and your JavaScript runs inside a prebuilt host , no Xcode, no Android Studio, no Mac. For an app like this (UI plus API calls) you can build the whole thing in Go.
You do not need a Mac to ship real binaries either: EAS Build compiles your iOS and Android apps on Expo cloud machines, and EAS Submit uploads them to the App Store and Play Store. You can build and release an iOS app from Windows or Linux.
You "exit Go" , switch to a custom development build (npx expo prebuild, or an EAS dev build with config plugins) , the moment you need a native capability Go does not bundle: Apple Health (HealthKit), the iOS 26 Liquid Glass UI, deeper camera/sensor or background features, in-app purchases. It is still a managed, cloud-buildable project; you are not dropping to a bare native repo.
So when do you actually need Xcode and a Mac? Only for local iOS builds, the iOS Simulator, or hands-on native debugging. With EAS cloud builds plus a real device (or TestFlight), you can develop, build, and release without ever opening Xcode.
Chapter 2 · Project: Mobile Fitness (Expo)
Sign up → an auto-recommended program
Ask three things, call programs.generate, and persist a dummy user.
The signup screen
Signup is a one-question-per-screen wizard: each step greets the user ("Hello Sarah") and asks one thing , goal, then days per week, then equipment , as big tappable answers with a progress-dot row. Answering the last step calls ymove.programs.generate with the collected answers and returns a full multi-week Program: a split plus a weeklySchedule of days, each day a list of exercises with sets, reps, and rest.
async function build() {
const program = await ymove.programs.generate({
goal, // 'muscle_building' | 'weight_loss' | 'strength' | 'endurance'
daysPerWeek: days, // 3..5
weeks: 4,
equipment: equipment === 'any' ? undefined : equipment,
});
onDone({ goal, daysPerWeek: days, equipment, program });
}Persist the dummy user
Save the answers and the recommended program so the user lands on their plan next launch. For the tutorial that is one dummy user in AsyncStorage , React Native local storage, which also works on web.
In a real app you would put this behind auth and store it in your database (and have your backend make the ymove calls). Only where the user comes from changes , the screens stay the same.
import AsyncStorage from '@react-native-async-storage/async-storage';
const USER_KEY = 'ymove-demo-user';
// undefined = loading, null = needs signup, object = signed up
useEffect(() => {
AsyncStorage.getItem(USER_KEY)
.then((raw) => setUser(raw ? JSON.parse(raw) : null))
.catch(() => setUser(null));
}, []);
function saveUser(u: DummyUser) {
setUser(u);
AsyncStorage.setItem(USER_KEY, JSON.stringify(u)); // swap for your DB in production
}Chapter 3 · Project: Mobile Fitness (Expo)
The program, workout & exercise screens
Three tabs: the recommended program, a workout library, and an exercise library.
The program screen
The Program tab is an agenda: a Week 1-4 selector across the top, and under it the weeklySchedule for that week as numbered day cards (Upper A, Lower A, …) with muscle groups and exercise count. Tapping a day opens the player on its exercises , the same WorkoutExercise[] shape a generated workout uses.
<FlatList
data={user.program.weeklySchedule}
keyExtractor={(d, i) => String(d.day ?? i)}
renderItem={({ item }) => (
<Pressable onPress={() => openPlayer({ title: item.name, exercises: item.exercises })} style={styles.dayCard}>
<Text style={styles.dayName}>{item.name}</Text>
<Text style={styles.rowMeta}>{item.muscleGroups.join(' · ')} · {item.exercises.length} exercises</Text>
</Pressable>
)}
/>The workout library
The Workouts tab is a quick generator: pick a muscle-group chip and call ymove.workouts.generate for a fresh six-exercise workout, then start the player. It re-runs whenever the chip changes.
useEffect(() => {
let off = false;
ymove.workouts.generate({ muscleGroup, difficulty: 'intermediate', exerciseCount: 6 })
.then((w) => !off && setWorkout(w))
.finally(() => !off && setLoading(false));
return () => { off = true; };
}, [muscleGroup]);The exercise library
Fetch once on mount and render rows with a thumbnail, title and meta. The same SDK thumbnailUrl works natively in <Image>; FlatList virtualises long lists for free.
useEffect(() => {
let off = false;
ymove.exercises.list({ hasVideo: true, pageSize: 30 })
.then((r) => !off && setExercises(r.data))
.catch((e) => !off && setBrowseErr(e?.message));
return () => { off = true; };
}, []);
<FlatList
data={exercises}
keyExtractor={(it, i) => it.id ?? String(i)}
renderItem={({ item }) => (
<Pressable onPress={() => onOpen(item.slug)} style={styles.row}>
<Image source={{ uri: item.thumbnailUrl }} style={styles.thumb} />
<View style={styles.rowText}>
<Text style={styles.rowTitle}>{item.title}</Text>
<Text style={styles.rowMeta}>{item.muscleGroup}</Text>
</View>
</Pressable>
)}
/>The detail screen
On tap, fetch full detail with exercises.get(slug) , the list payload is light, get() returns instructions and the video. Render the (portrait) clip and numbered steps in a ScrollView.
ymove clips are portrait, so the container is aspect 9:16 with the image set to cover , never letterbox a vertical clip into a 16:9 box.
const [ex, setEx] = useState<Exercise | null>(null);
useEffect(() => { ymove.exercises.get(slug).then(setEx); }, [slug]);
<View style={[styles.videoWrap, styles.portrait]}> {/* portrait: aspectRatio 9/16 */}
<Image source={{ uri: ex.thumbnailUrl }} style={styles.video} resizeMode="cover" />
</View>
{ex.instructions?.map((step, i) => (
<View key={i} style={styles.stepRow}>
<Text style={styles.stepNum}>{i + 1}</Text>
<Text style={styles.stepText}>{step}</Text>
</View>
))}Chapter 4 · Project: Mobile Fitness (Expo)
Build the workout player
A Reels-style portrait player: video hero, next-up preview, big numerals, transport.
What you are building
Next
Incline Dumbbell Press
Barbell Bench Press
chest
Rest 90s between sets
Step through the workout , the same component, fed by workouts.generate.
Step through it. A full-screen portrait player: the clip fills the frame, a "Next" tile previews the upcoming move, giant set × reps numerals read at a glance, and a thumb-zone transport walks through the workout. On desktop web a max-width keeps it a phone-width column.
Portrait video hero
Because ymove clips are portrait (~9:16), let the video fill the available space (flex: 1) with resizeMode="cover" so it never letterboxes. Here the thumbnail is a fallback , swap in expo-av's <Video> for real playback.
Pin a small "Next: ___" tile in the corner so the user can anticipate the next move , the Fitplan pattern.
<View style={[styles.videoWrap, { flex: 1 }]}>
<Image source={{ uri: current.exercise.thumbnailUrl }} style={styles.video} resizeMode="cover" />
{next && (
<View style={styles.nextPreview}>
<Text style={styles.nextLabel}>Next</Text>
<Text style={styles.nextTitle}>{next.exercise.title}</Text>
</View>
)}
</View>
// styles
videoWrap: { marginHorizontal: 20, marginTop: 16, borderRadius: 12, overflow: 'hidden', backgroundColor: '#1f1f1f' },
video: { width: '100%', height: '100%', resizeMode: 'cover' },Numerals + transport + desktop width
Giant tabular set × reps numerals read mid-set. The transport (prev / done / next) sits in the thumb zone and walks activeIndex through the exercises. One outer shell , a centered, max-width column that every screen renders inside , keeps the app a tidy phone-width column on desktop web instead of each screen re-centering and jumping.
<View style={styles.bigNumbers}>
<Text style={styles.bigNumber}>{current.sets}</Text>
<Text style={styles.bigSep}>×</Text>
<Text style={styles.bigNumber}>{current.reps}</Text>
</View>
<View style={styles.transport}>
<Pressable onPress={onBack} disabled={index === 0}><Text>‹</Text></Pressable>
<Pressable onPress={onAdvance} style={styles.transportBtnPrimary}><Text>Done set</Text></Pressable>
<Pressable onPress={onAdvance} disabled={index === last}><Text>›</Text></Pressable>
</View>
// one stable shell centers a phone-width column on desktop web (no per-screen jump)
outer: { flex: 1, alignItems: 'center', backgroundColor: '#000' },
column: { flex: 1, width: '100%', maxWidth: 440, backgroundColor: '#0a0a0a' },Common questions
- The exercise video will not play , why?
- The most common cause is an expired URL: ymove video links are signed and expire after 48 hours, so a cached URL stops working , refetch the exercise to get a fresh one. For playback use expo-video (or react-native-video) and size it to a portrait container with resizeMode="cover".
- Can I sync with Apple Health, Google Fit, or Health Connect?
- Yes. ymove supplies the exercise content and workout structure; pair it with react-native-health (Apple Health), Google Fit, or Health Connect for device data like steps and heart rate. They are complementary , the SDK does not touch device sensors.
- Is React Native a good choice for a fitness app?
- Yes. The SDK is just fetch under the hood, so the exact client code from the web examples runs unchanged , one codebase for iOS, Android, and web, with no native modules required to call the API.