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.
3 chapters · ~75 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)
Build the browse screen
A native FlatList from exercises.list, tapping a row opens a detail view.
Load exercises into a FlatList
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 3 · 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 workout.exercises. A maxWidth on the root keeps the app a phone-width column when run on the web.
<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>
// keep it a phone column on desktop web
root: { flex: 1, backgroundColor: '#0a0a0a', width: '100%', maxWidth: 440, alignSelf: 'center' },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.