← Academy

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.

~20 min · IntermediateView on GitHub →

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 && npm install ymove-exercise-api expo-constants
terminal
npx create-expo-app ymove-mobile
cd ymove-mobile
npm install ymove-exercise-api expo-constants

The 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.

lib/ymove.ts
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.

npm start # then press i (iOS), a (Android), or w (web)
App.tsx
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.

eas build --platform ios # cloud build, no Mac required

Chapter 2 · Project: Mobile Fitness (Expo)

Build the browse screen

A native FlatList from exercises.list, tapping a row opens a detail view.

~25 min · IntermediateView on GitHub →

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.

App.tsx
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.

App.tsx
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.

~30 min · IntermediateView on GitHub →

What you are building

Live demoReact Native player (portrait video)
Exercise 1 of 4
portrait clip

Next

Incline Dumbbell Press

Barbell Bench Press

chest

4×6-8

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.

App.tsx
<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.

npm start
App.tsx
<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.