← All posts

How to build a fitness app in React Native with Expo (screens, stack, and code)

The screens a fitness tracking app needs, the Expo and React Native stack to build them with, and the routing, theming and chart code to get from empty project to working app.

September 14, 2026 · Thomino

Fitness apps are one of the best first mobile projects. The domain is simple to understand, the data model is small, and every screen type you'll ever need shows up somewhere: an onboarding quiz, a dashboard with charts, forms, lists, a detail view, a live tracker, and a paywall. Build one and you've learned most of React Native.

This post walks through what to build and how, using the stack we use for Caloria, our fitness and meal tracking template. Everything here applies whether you start from scratch or from the template.

What a fitness app actually needs

Before writing code, list the screens. Here's the set that covers a calorie and workout tracker end to end, taken from Caloria's route tree:

Onboarding and auth

  • Welcome screen
  • Onboarding quiz: sex, birthday, height, current weight, weekly weight trend, exercise frequency
  • Login, signup, forgot password
  • Notification and location permission prompts

Main tabs

  • Dashboard: greeting, weight trend for the last 7 days, calories this week, sleep, water intake
  • Meals: today's log with add-meal entry
  • Workouts: recent and available workouts, each opening a detail screen and a live tracker
  • Progress: weight history with entries, current weight, and weight lost

Everything else

  • Add meal, add water, add weight, add workout
  • Workout detail and workout tracker
  • Profile and edit profile
  • Notifications list
  • Connected devices
  • Subscription paywall
  • Help

That's 25 screens. The dashboard and progress tab are the two that make or break the app, because they're where users see whether their effort is working.

The stack

This is what Caloria runs on, and what I'd pick for a new fitness app today:

NeedPackage
FrameworkExpo SDK 57, React Native 0.86, TypeScript
NavigationExpo Router with a drawer wrapping a tab navigator
StylingNativeWind v4 (Tailwind classes in React Native)
Chartsvictory-native for line and area charts, react-native-chart-kit for quick bar charts
Animationreact-native-reanimated and react-native-gesture-handler
Long lists@shopify/flash-list
Dates@react-native-community/datetimepicker
Remindersexpo-notifications
Photosexpo-image-picker for progress photos and meal photos
Iconslucide-react-native

No backend is in that list on purpose. Get the UI right against local mock data first. Wiring Supabase or Firebase later is a day's work; redesigning screens around a data model you got wrong is a week's.

Project structure

Expo Router maps your file tree to routes. This layout gives you a drawer for secondary navigation and tabs for the four main screens:

app/
  _layout.tsx                 # root: providers, fonts, theme
  (drawer)/
    _layout.tsx               # drawer navigator
    (tabs)/
      _layout.tsx             # tab bar
      index.tsx               # dashboard
      meals.tsx
      workouts.tsx
      progress.tsx
  screens/
    onboarding.tsx
    add-meal.tsx
    add-weight.tsx
    workout-detail.tsx
    workout-tracker.tsx
    subscription.tsx
    ...
  contexts/
    ThemeContext.tsx
    ThemeColors.tsx
components/
  Card.tsx, Header.tsx, Button.tsx, ProgressBar.tsx,
  BarChartCard.tsx, SmallChartCard.tsx, MultiStep.tsx, ...
utils/
  color-theme.ts

Screens that aren't tabs live under screens/ and are pushed on top of the tab stack, so the tab bar hides while you're adding a meal and comes back when you're done.

Theming from day one

Fitness apps get used at 6am and 11pm. Dark mode isn't optional. The cheapest way to support it is to never write a color in a component. Define your palette as CSS variables in a Tailwind config, expose them as classes like bg-background, text-subtext and bg-highlight, and switch the variable set when the theme changes.

// utils/color-theme.ts (excerpt)
export const themes = {
  light: {
    '--color-background': '#FFFFFF',
    '--color-secondary': '#F5F5F5',
    '--color-text': '#0A0A0A',
    '--color-subtext': '#737373',
    '--color-highlight': '#00A6F4',
  },
  dark: {
    '--color-background': '#0A0A0A',
    '--color-secondary': '#171717',
    '--color-text': '#FFFFFF',
    '--color-subtext': '#A3A3A3',
    '--color-highlight': '#00A6F4',
  },
};

Then every component is written once:

<View className="bg-secondary rounded-2xl p-4">
  <ThemedText className="text-subtext text-xs uppercase">Calories</ThemedText>
  <ThemedText className="text-2xl font-bold">1,840</ThemedText>
</View>

For chart libraries and icons that need a JS color value rather than a class, keep a useThemeColors() hook that returns the same palette as an object.

The onboarding quiz

The quiz is a multi-step form. Each step is one question, the answer is stored in local state, and the last step computes a daily calorie target. Build it as a generic MultiStep component that takes an array of step components and handles progress and navigation, then the quiz itself is just data:

const steps = [
  { key: 'sex', title: 'What is your sex?', component: SexStep },
  { key: 'birthday', title: 'When is your birthday?', component: BirthdayStep },
  { key: 'height', title: 'How tall are you?', component: HeightStep },
  { key: 'weight', title: 'What is your current weight?', component: WeightStep },
  { key: 'trend', title: 'How was your weight past week?', component: TrendStep },
  { key: 'activity', title: 'How often do you exercise?', component: ActivityStep },
];
 
<MultiStep steps={steps} onComplete={(answers) => router.replace('/(drawer)/(tabs)')} />

Keep each step to a single tap where you can. Selectable chips beat text inputs for sex, trend and activity. A wheel picker beats typing for height and weight.

The dashboard

The dashboard is a scroll of cards. Each card owns its own data and chart, so you can reorder or remove them without touching anything else. A weight trend card with victory-native looks like this:

import { CartesianChart, Line } from 'victory-native';
 
function WeightTrendCard({ data }: { data: { day: string; kg: number }[] }) {
  const colors = useThemeColors();
  return (
    <Card title="Weight Trend" subtitle="Last 7 days">
      <View style={{ height: 120 }}>
        <CartesianChart data={data} xKey="day" yKeys={['kg']}>
          {({ points }) => <Line points={points.kg} color={colors.highlight} strokeWidth={3} curveType="natural" />}
        </CartesianChart>
      </View>
    </Card>
  );
}

Repeat the pattern for calories this week, water intake and sleep. Small cards in a two-column grid, a full-width card for the primary metric.

Logging entries

Add-weight, add-water and add-meal are the same screen with different fields: a header with a back button, one or two inputs, a date picker defaulting to now, and a full-width save button. Write one EntryForm layout and pass the fields in. The important detail is that saving should navigate back and the tab underneath should already show the new entry. With local state that's a context update; with a backend it's an optimistic write.

The workout tracker

The tracker is the one screen with real interaction: a timer, the current exercise, sets and reps, and next and previous controls. Keep the timer in a useEffect with setInterval, store elapsed seconds in state, and derive the display string. Use react-native-reanimated for the progress ring so it doesn't stutter while the timer ticks.

Reminders

Water and workout reminders are the single feature that most improves retention, and expo-notifications makes local scheduling a few lines:

import * as Notifications from 'expo-notifications';
 
await Notifications.scheduleNotificationAsync({
  content: { title: 'Time to drink water', body: 'Log a glass to stay on track.' },
  trigger: { hour: 14, minute: 0, repeats: true },
});

Ask for permission on a dedicated screen after onboarding, with a sentence explaining why. Permission prompts fired on first launch get denied.

The paywall

Design the subscription screen even if you don't sell anything yet: three plan cards, one highlighted, a feature list, and a restore purchases link. When you're ready, RevenueCat or the native StoreKit and Play Billing wrappers plug into that screen without changing its layout.

Ship it

Once the screens work against mock data:

  1. Run npx expo-doctor and fix what it reports.
  2. Set your bundle identifier and package name in app.json.
  3. Replace mock data and placeholder images.
  4. Build with eas build or locally with npx expo run:ios and npx expo run:android.

Or start from Caloria

Everything above exists, working, in Caloria: the 25 screens, the drawer and tab navigation, the theme system with light and dark mode, 52 reusable components, and the charts. It's on Expo SDK 57.0.22, TypeScript throughout, and it's a front-end template with local mock data, so you connect whatever backend you like. It costs $99, or you can get every template together.

If you'd rather build it yourself, the structure and patterns above are the ones the template uses, and they'll hold up as the app grows.