It was a Saturday. I was on the couch in sweatpants, a half-cold coffee on the floor, and the Expo bar was climbing on my laptop. Upload to TestFlight. 12%. 34%. 71%. The cat had claimed my chest as the optimal nap surface and I could not move without consequences. So I watched the bar, and around 88% I realized something disorienting: the app about to ship started seven hours earlier as a sentence in a Notes file. Vibe coding mobile apps, as it turns out, is now a Saturday-shaped activity.
A habit tracker. Five screens. Local storage. Push notifications. Some basic analytics. Done.
Six months ago this would have been a 3-week side project that I'd quietly abandon at week 2. Today it was a Saturday. If you have never used Claude Code before, the beginner tutorial will get you from install to first working session in about fifteen minutes.
This post is a walkthrough of that Saturday. The honest version, with the parts that hurt. The mobile flavor of vibe coding is a different beast from web vibe coding, and I want to show you both what melts away under Claude Code and what stubbornly refuses to. Halfway through, my friend Elena, who has actually shipped iOS apps for a living since the iPhone 6 days, will jump in to talk about the gotchas no LLM can reason its way out of. Then I'll come back for deployment.
If you're new to the term, the introduction to vibe coding is worth reading first. The mobile flavor builds on the same instincts.
What we're building
A habit tracker. The kind of app you've seen on the App Store five hundred times, which is exactly why it's a good teaching case: the surface area is familiar, the requirements are real, and the craft lives in the details.
Here's the spec I started with:
- Today screen with the day's habits and tap-to-complete
- Add Habit screen with name, frequency, reminder time
- History screen with a 30-day grid view per habit
- Settings screen with notification toggles and data export
- Onboarding flow for first launch
Storage is local with AsyncStorage. No accounts, no cloud sync (yet). Reminders use Expo Notifications. Analytics is a homemade streak calculator, no third-party SDK.
The stack:
- Expo (managed workflow) for React Native
- TypeScript because future-me always thanks present-me
- Expo Router for file-based navigation
- AsyncStorage for persistence
- Expo Notifications for local reminders
I went with managed Expo for the same reason I order the chef's tasting menu: someone who has done this hundreds of times has already decided which corners are worth cutting. We evaluated the full landscape in our vibe coding tools comparison, and for mobile work specifically, the Expo plus Claude Code pairing had the least friction. Bare React Native gives you control, but it also gives you native module headaches, Pod install errors at 11pm, and a slower iteration loop. Managed Expo gives me OTA updates, easier TestFlight builds via EAS, and one codebase that runs on iOS and Android without me opening Xcode unless I want to. That trade is, for a Saturday project, a no-brainer.
Setup: the actual commands
Here's the entire bootstrap. No magic, no hidden steps:
npx create-expo-app habit-tracker --template
cd habit-tracker
npm install expo-notifications @react-native-async-storage/async-storage
npx expo startBefore that worked, I had a few preflight chores. Mobile dev tooling is the kitchen mise en place you wish someone had told you to do before opening the cookbook:
- Xcode installed (macOS only) with command-line tools, accepted licenses, at least one iOS simulator runtime downloaded
- Android Studio installed with an Android Virtual Device set up, plus
adbon my PATH - Expo Go installed on my actual iPhone, signed into the same Expo account I was using on my laptop
- A USB-C cable that actually carries data, not just charge (this one ate forty-five minutes of my life last year)
A small piece of napkin math: Xcode plus the iOS simulator runtime is roughly 30GB of disk. Android Studio plus one emulator image is another 12GB. Budget the disk before you budget the day. And budget the real cost of vibe coding too: mobile projects tend to burn more tokens than web projects because platform-specific edge cases multiply the back-and-forth. The Expo docs cover the install paths well: docs.expo.dev is bookmark-worthy.
The simulator is fast and convenient. The simulator also lies. Notifications behave differently. Camera APIs behave differently. Performance is unrepresentative. I learned to do every meaningful smoke test on the actual phone in my pocket, even when the simulator was right there. The split rule I now use: the simulator is for layout, the device is for truth.
Workflow 1: Initial scaffolding
The first thing I asked Claude Code, after cd'ing into the project, was something close to this:
"Build me 5 screens for a habit tracker using Expo Router: Today, Add Habit, History, Settings, Onboarding. Use TypeScript. Create a /components folder with HabitCard, PrimaryButton, and Input. Use a tab layout for the main four screens and a stack for onboarding. Don't add any state management library yet. Use placeholder data."
Sixty seconds later I had an app/ directory full of routes, a components/ folder, a tab layout file, and a stack layout for onboarding. It worked on the first run. I tapped through the tabs on my phone in Expo Go and felt the small electric thrill of seeing your idea move under your thumb.
Now, the honest part. The first scaffold looked generic. Default fonts. Default spacing. The kind of UI that screams "I was generated in sixty seconds." That is fine. That is, in fact, the entire point. The scaffold is the rough block of marble. The next two hours are where you start carving.
I iterated by feeding Claude Code my actual design preferences: "all cards should be 16 corner radius, 12 padding, hairline border in light gray, and the whole app should use SF Pro on iOS and Roboto on Android by default." I gave it a small color palette in a theme.ts file. I told it I wanted big tap targets and chunky type. The second pass looked like something I'd actually built.
This is the rhythm of vibe coding mobile apps that I've fallen into: the first prompt is for shape, the next ten are for soul.
Workflow 2: State and persistence
Once the screens existed, I needed habits to actually persist. I want to add a habit on Tuesday and still see it on Thursday. AsyncStorage is the obvious answer for a single-user, single-device, offline-friendly app this small.
Here's the prompt I used:
"Use AsyncStorage to persist habits and completion data. Create a useHabits hook that returns habits, addHabit, deleteHabit, toggleCompletion. Keys should be namespaced under 'habit-tracker:'. Don't add Zustand, Jotai, MMKV, or any other state library. Plain React state and a custom hook is fine."
That last sentence is load-bearing. Without it, Claude Code reaches for Zustand the way a chef reaches for butter. Or it suggests MMKV because it's faster than AsyncStorage. Both are reasonable choices in larger apps. Neither is what I wanted on a Saturday.
This is where having a good CLAUDE.md file in the project root is worth its weight. Mine for this project was four bullet points: no extra libraries without asking, plain hooks for state, AsyncStorage for persistence, all UI in TypeScript. The agent honored it. If you want a deeper bench of these prompting tricks, the 50 Claude Code tips post has a good chunk on per-project memory files.
The resulting hook was about ten lines of meaningful code, plus types:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
const KEY = "habit-tracker:habits";
export function useHabits() {
const [habits, setHabits] = useState<Habit[]>([]);
useEffect(() => {
AsyncStorage.getItem(KEY).then(raw => raw && setHabits(JSON.parse(raw)));
}, []);
const persist = (next: Habit[]) => {
setHabits(next);
AsyncStorage.setItem(KEY, JSON.stringify(next));
};
return { habits, addHabit: (h: Habit) => persist([...habits, h]) };
}Immutable updates by default. Every change creates a new array. The persistence layer is dumb on purpose. There are corners to cut here (debouncing writes, splitting by key, schema versioning) but for a Saturday MVP this gets out of the way.
Workflow 3: Push notifications, the gnarly part
Notifications are where the smooth Saturday becomes a slightly bumpy Saturday afternoon. I budgeted thirty minutes for notifications and spent two hours. That ratio is, in my experience, the universal constant of mobile development. Whatever you think notifications will take, double it, then add lunch.
Here's what makes them gnarly:
- iOS requires explicit permission, plus background modes if you want anything fancy
- Android requires you to declare a notification channel before scheduling anything, or notifications silently fail on Android 8+
- Local scheduling has different APIs on each platform under the hood
- Reminder times behave subtly differently: iOS treats "tomorrow at 8am" as wall-clock time, Android needs you to be careful around timezone changes
The prompt that got me 80% of the way:
Set up Expo Notifications for local reminders.
On first run after onboarding, request permission.
Create an Android notification channel called "reminders" with high importance.
For each habit with reminderTime set, schedule a daily local notification.
When a habit is deleted, cancel its scheduled notification.
Use the habit ID as the notification identifier.
Claude Code wrote a notifications.ts utility that did all of this, plus error handling, in about forty lines. The remaining 20% was me going back and forth on edge cases: what happens if permission is denied, what if the user disables a habit, what if they reinstall the app. None of those are interesting in isolation. All of them have to work.
I'll say this plainly: Expo Notifications is the area where Expo is still not as smooth as it is everywhere else. Our guide to debugging vibe coded apps has a whole section on tracking down platform-specific bugs like these, where the agent's output compiles fine but behaves differently on each OS. If your app is notifications-heavy, do a small spike before you commit to the stack.
Elena here: the platform-specific gotchas Claude can't reason its way out of
Hi, Elena. Alex handed me the keyboard because I've been shipping iOS apps since the home button era and these are the rocks I see people trip over with mobile vibe coding, almost exactly in this order.
I want to start with a meta point. Mobile platforms drift faster than the LLM training cycle. iOS 18 changed how SafeArea works. Android 15 made edge-to-edge mandatory for new apps. Expo SDK 53 deprecated three different APIs I had memorized. An LLM trained six months ago will confidently give you advice that was correct nine months ago and is wrong today. It's not lying. It's just stale. The fix is not to stop using Claude Code. The fix is to feed it fresher context.
Here is my list of things to actively defend against.
iOS keyboard avoiding behavior. KeyboardAvoidingView is the API everyone reaches for, and it works most of the time. The problem is the behavior prop: padding works on iOS, height works sort of on Android, and position works on neither reliably. I keep a wrapped component called <KeyboardSafe> in every project that handles the platform branching once, and I reference that component name in my prompts so Claude Code uses it instead of inventing a new one each screen.
Android back button handling. iOS doesn't have a system back button. Android does. If you don't add a BackHandler listener to your modals and forms, the user can blow past your "are you sure?" confirm dialog with a single hardware press. The first time a tester reports "I lost my work when I tapped back" you will feel a particular kind of regret. Always wire the back button on Android, always.
SafeAreaView vs SafeAreaProvider. This is one of the most common things Claude Code gets wrong because the recommendation has changed at least three times in the React Native ecosystem. The current correct pattern is to wrap your app in a SafeAreaProvider from react-native-safe-area-context and use the useSafeAreaInsets hook in screens. The old SafeAreaView from react-native itself is a bit of a trap on Android. I show that distinction explicitly to the agent.
iOS dark mode auto-detection. If you don't opt your app into dark mode in app.json and react to Appearance.getColorScheme() correctly, your app will look right in light mode and sad in dark mode. The fix is one line in app.json ("userInterfaceStyle": "automatic") plus a theme provider that responds to scheme changes. Easy, but easy to forget.
Android edge-to-edge mode. New Android versions render content under the system bars by default. If you don't apply insets, your bottom tab bar will be partially under the navigation gesture area. There is nothing more amateur-feeling than a button you can't quite tap. Edge-to-edge is the most important post-2024 thing to get right, and the LLM will not bring it up unless you do.
What I do, on every project, is keep a markdown file in the repo called mobile-platform-notes.md that lists my current opinions on each of these. Then I ask Claude Code to read it as context before any UI work. Think of it as a chef's notebook. The recipes change, the kitchen changes, but the notebook is yours and it stays current because you maintain it.
That's me. Back to you, Alex.
Workflow 4: API integration
For the analytics backend, I wired the app up to a small Vercel-hosted Next.js route handler with a Postgres database. That backend already existed from another project, so this section is about the mobile side: a fetch wrapper that handles auth, retries, and offline queueing.
Prompt:
Create an apiClient utility with these capabilities:
- Base URL from EXPO_PUBLIC_API_URL env var
- Bearer token auth from AsyncStorage key "habit-tracker:token"
- Automatic retry once on 5xx with 1s backoff
- If offline, queue POST/PATCH requests and replay on reconnect using NetInfo
- Strongly typed: takes a Zod schema for response validation
Claude Code produced a clean module. Typed input, typed output, retry logic, an in-memory queue that flushed on NetInfo reconnect events. I read every line because network code is where bugs go to live forever.
A real-world catch worth flagging: AsyncStorage has size limits. Roughly 6MB on Android by default, and on iOS you should treat it as bounded too. If your app stores anything larger than user preferences and a few hundred small records, switch to expo-sqlite early. Migrating later is painful. I learned this the hard way on a different project where 18 months of journal entries hit the ceiling.
The screen-level integration looked like this:
const { data, error } = useQuery({
queryKey: ["streak", habitId],
queryFn: () => apiClient.get(`/streak/${habitId}`, StreakSchema),
});That's it. The hard work is in the wrapper. The screen code stays calm.
Deployment: TestFlight and Play Store
The deployment story is where Expo earns its keep. I did not open Xcode once during this whole project. That is not a brag. That is a feature.
Two commands shipped this thing:
eas build --platform ios --profile production
eas submit --platform ios --latestEAS Build runs the build in the cloud, on Apple's tooling, with code signing handled automatically through your Apple Developer account. You set it up once and forget. It takes about twelve minutes per build. You can do other things while you wait. I made a sandwich.
Once submitted, TestFlight picks up the build in about twenty minutes. I added myself and three friends as Internal Testers, which requires no review and is instant. To go to External Testing (up to 10,000 testers), Apple does a brief review that's been taking 24 to 72 hours in my experience. Apple's official walkthrough at developer.apple.com covers the steps if you're new to it.
For Android, the same flow with --platform android, then eas submit --platform android with a Google Play service account JSON. Internal Testing on Play Console is fast (an hour or two for review). Open Testing requires a longer review and a privacy policy URL.
Code signing on iOS used to be a hellscape of provisioning profiles, distribution certificates, and "I just want to ship a build" tears. EAS abstracts the entire thing. That alone is worth the Expo premium for anyone who isn't shipping iOS apps weekly. If you do want to script the App Store Connect side of submissions further, the App Store Connect CLI deployment automation post goes deep on that.
The build pipeline that actually shipped this app to TestFlight, end to end on a Saturday afternoon, was three commands and one cup of coffee.
What I'd never do without Claude Code anymore
The boring middle of mobile work. All of it. In specific:
- The boilerplate of new screens (route file, layout, basic component shell)
- The boring fetch-plus-state plumbing
- Reading platform documentation to find the one config flag I need
- Writing TypeScript types for API responses, especially when the backend is mine
- Configuring
app.jsonfor icons, splash screens, deep links, URL schemes - Migrating between Expo SDK versions when the changelog is twenty-eight bullets long
- Writing the eighteenth
useStateof the day
This stuff used to take entire afternoons. Now it takes ten minutes per task and frees me to think about the thing that matters.
What I still do manually
The judgment work. The taste work. The stuff that only matters because a human did it.
- Real interaction design (swipes, gestures, the feel of an animation)
- Copy and microcopy, especially the empty states and the error messages
- The "what should this app feel like to use" judgment
- App Store submission decisions: screenshots, description, ASO keywords
- Testing on actual devices, in real conditions, with real fingers
- Deciding what not to build
A specific example. The "you completed your first habit" celebration animation. Claude Code can write the spring animation code in twelve seconds. It cannot decide that the confetti should be muted instead of bright, or that the toast should say "Day one. Nice." instead of "Habit completed!" That decision is the entire reason an app feels like itself instead of feeling like generic mobile slop. We cataloged dozens of these taste-level lessons in our iOS app development lessons from 10 apps post, and the pattern holds across React Native too.
The split is, roughly: the agent does the typing, I do the thinking. And honestly, that division of labor is the one I've wanted my whole career.
A Saturday is enough now
I shipped the app. The cat eventually got off my chest. I made a second coffee. The TestFlight invite went to my friends and one of them texted back twenty minutes later with "wait, you built this today?" and I felt the small electric thrill of yes, I did, and it didn't take a heroic effort.
Mobile used to be the place I went when I wanted to slow down. Three weeks of native module config, two weeks of build settings, one week of actual product work. Now it's where I go when I want to ship something this weekend. The platforms are the same. The friction is gone.
If you've been holding off on a mobile idea because it felt too heavy, try the habit tracker pattern this week. Five screens, AsyncStorage, one notification, a TestFlight build. See how far you get on a single Saturday. The worst case is you have a working prototype on your phone by Sunday morning.
Tell me what you build. I'll be on the couch.