Mobile App Development
Building and shipping production iOS and Android apps with React Native and Expo.
Choosing your approach
Before writing code, understand what you're committing to, because this decision is expensive to reverse.
Native (Swift for iOS, Kotlin for Android). Best performance, immediate access to every platform API, and the most predictable behaviour. You maintain two separate codebases written in two languages. For a solo developer or small team, this roughly doubles the work for most applications.
React Native. One JavaScript or TypeScript codebase producing genuinely native UI on both platforms. Performance is close enough to native for the overwhelming majority of applications. Access to platform features requires either an existing library or writing a small amount of native code.
Expo. A managed layer over React Native that handles build tooling, native module configuration, over-the-air updates, and submission. Historically it restricted you to a fixed set of native modules; the modern config plugin system removed that limit, and you can now add arbitrary native dependencies while keeping the tooling.
Flutter. Single codebase, excellent performance, its own rendering engine, and Dart as the language. Genuinely good. The main cost is that Dart skills transfer nowhere else, whereas React Native knowledge overlaps heavily with web development.
A defensible default for most projects: React Native with Expo and TypeScript. It gives you one codebase, one language shared with your web work, mature tooling, and no meaningful ceiling for typical business applications. Reach for native only when you have a specific requirement — heavy real-time graphics, intensive background processing, or deep platform integration — that you've actually confirmed rather than assumed.
When not to build an app at all. If your product is essentially a website, a responsive web app costs a fraction as much and updates instantly. Apps are justified by push notifications, offline capability, camera and sensor access, home screen presence, or a genuine need for the app store as a distribution channel. Clients frequently ask for an app when a web app would serve them better, and telling them so is worth more than the larger invoice.
Project structure and TypeScript discipline
The decisions in the first week determine how painful month six is.
Use TypeScript from the start. Retrofitting types onto an untyped codebase is far more work than writing them as you go. Set strict to true immediately — turning it on later means fixing hundreds of errors at once.
Organise by feature, not by file type. A folder per feature containing its screens, components, hooks, and types beats top-level components, screens, and hooks folders. Features get added and removed as units; file types don't.
Use file-based routing. Expo Router maps your directory structure to navigation, which removes a whole category of configuration and makes deep linking largely automatic.
Separate your data layer from your UI. Components should call hooks; hooks should call a data module; the data module talks to your backend. When the backend changes, you edit one layer. When components call the API directly, a schema change means touching thirty files.
Type your backend contract. If you're on Supabase, generate types from your database schema and regenerate them whenever it changes. This turns a class of runtime failures into compile-time errors, and it's the single highest-return piece of tooling in this stack.
Establish these on day one: linting and formatting on save, a pre-commit hook, path aliases so imports don't become relative-path archaeology, and environment configuration that separates development, staging, and production. Each takes minutes at the start and hours to introduce later.
Start the project:
npx create-expo-app@latest myapp --template expo-template-blank-typescript
cd myapp
npx expo install expo-router expo-secure-store
npm i @tanstack/react-query
The folder shape that survives month six:
app/ # routes — the file path IS the URL
(tabs)/
index.tsx
bookings.tsx
booking/[id].tsx
features/
bookings/
api.ts # every network call for this feature
useBookings.ts # the hook screens actually call
BookingCard.tsx
types.ts
lib/
supabase.ts
Turn strict on before you write anything. In tsconfig.json:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }
noUncheckedIndexedAccess is the one nobody enables and everybody needs — it forces you to handle array[0] being undefined, which is the crash your users find and you never do.
State, data, and offline
Most app complexity lives here, not in the interface.
Separate server state from client state. These are different problems and conflating them causes most state management pain.
Server state is data that lives on your backend — it can be stale, it needs caching, refetching, and loading and error handling. Use a library built for it (TanStack Query or similar). Do not put server data in a global store and manage it manually; you will reimplement caching badly.
Client state is UI concerns — which tab is open, what's in a form, whether a modal is showing. Most of this belongs in local component state. A small global store handles the genuine cross-cutting cases like the current session.
Assume the network is unreliable. Mobile devices go through tunnels and into buildings with no signal. Every network call needs a loading state, an error state, and a retry path. An app that shows a permanent spinner when the request failed is the most common defect in amateur mobile work.
Optimistic updates where it matters. Update the interface immediately and reconcile when the server responds. This is the difference between an app that feels fast and one that feels sluggish, and it costs little.
Decide your offline posture explicitly. Full offline-first with sync and conflict resolution is a substantial engineering project — budget for it as one, or scope it out in writing. Read-only caching of previously fetched data is far cheaper and covers most real user needs. Choose deliberately rather than discovering the requirement at week eight.
Persist the session properly. Auth tokens go in secure storage, not AsyncStorage. Handle expiry and refresh silently. Being logged out unexpectedly is one of the fastest ways to lose a user.
// lib/secure-store.ts — Supabase expects this exact shape
import * as SecureStore from 'expo-secure-store';
export const secureStorage = {
getItem: (key: string) => SecureStore.getItemAsync(key),
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
};
The three states, every time. This is the shape of every screen that loads data, and the one people forget is the middle one:
const { data, isLoading, error, refetch } = useBookings();
if (isLoading) return <Skeleton />;
if (error) return <ErrorState onRetry={refetch} />; // ← the forgotten one
if (!data?.length) return <EmptyState />;
return <BookingList bookings={data} />;
An app that shows a spinner forever when the request failed is the single most common defect in amateur mobile work. It is four lines to fix.
Ask the AI for this specifically:
Add the bookings list screen. It must handle loading, error with a retry button, and empty separately — not one combined state. Show me the file.
Building things that feel native
Cross-platform frameworks make it easy to ship something that works and feels wrong. The gap is in details.
Respect platform conventions. Back navigation, share sheets, date pickers, and haptics differ between iOS and Android. Users notice when an app fights their platform's habits even if they can't articulate why.
Handle safe areas. Notches, dynamic islands, home indicators, and Android status bars. Content underneath any of these looks broken and it's the most common visual defect in first releases.
Get keyboard handling right. Inputs hidden behind the keyboard, forms that can't scroll, and dismissal that doesn't work are the most frequently reported usability problems in mobile apps. Test every form on a small device.
Performance discipline. Use virtualised lists for anything long. Memoise expensive renders. Keep images appropriately sized — full-resolution photos in a list will stutter. Run animations on the native thread rather than the JavaScript thread.
Touch targets. Around 44 points minimum. Interfaces designed on a desktop monitor consistently ship targets too small for thumbs.
Test on real devices, and old ones. Simulators hide performance problems and don't reproduce real network conditions, camera behaviour, or permission dialogs. Keep an old cheap Android phone specifically for this — it will surface problems no flagship device shows you.
Accessibility. Labels on interactive elements, adequate contrast, support for larger text sizes. Beyond the ethics, it's increasingly a procurement requirement for public sector and enterprise clients.
Shipping — builds, stores, and release
The part that catches out developers who are otherwise competent, because it's process rather than programming.
Set up cloud builds early. Configure your build pipeline in week one and produce a real build immediately, even of an empty app. Discovering a signing or provisioning problem the week before launch is a bad experience that's entirely avoidable.
Credentials. Apple's provisioning system — certificates, identifiers, profiles, capabilities — is genuinely confusing the first time. Let the tooling manage it if possible. For client work, have the client create their own Apple Developer and Google Play accounts and add you to them. Publishing under your own account creates a mess when the relationship ends and can constitute a breach of the store agreements.
Budget real time for review. Apple's review is unpredictable — often a day, sometimes considerably longer, and rejections happen for reasons you didn't anticipate. Never promise a client a launch date that assumes first-time approval.
Common rejection causes worth pre-empting: an incomplete privacy policy or inaccurate privacy labels, sign-in required before the user can see any value, missing account deletion (mandatory for apps with accounts), placeholder content, broken links, and payment for digital goods handled outside the store's own system. That last one is worth understanding properly, because the rules differ between digital content and real-world goods and services.
Over-the-air updates. JavaScript changes can be pushed without a store review, which is a significant operational advantage. Native changes still require a full submission. Know which is which, and understand that the stores permit OTA updates for bug fixes and improvements, not for materially changing what the app does.
Instrument before launch. Crash reporting and basic analytics from day one. Without crash reporting you will hear "it doesn't work" and have nothing to act on.
Staged rollout on Android. Release to a small percentage first and watch the crash rate. iOS has phased release; use it.
The commands you will actually run:
npm i -g eas-cli && eas login
eas build:configure
eas build --profile preview --platform ios # internal testers
eas build --profile production --platform all # store builds
eas submit --platform ios # straight to App Store Connect
eas update --branch production --message "Fix booking time zone"
That last one is the reason to use Expo. eas update ships JavaScript changes to users without a store review — a copy fix or a broken-button fix goes out in minutes rather than days. Native changes still need a full build; JS changes do not.
Set the version and build number before you submit, in app.json:
{ "expo": { "version": "1.2.0", "ios": { "buildNumber": "14" }, "android": { "versionCode": 14 } } }
Apple rejects a build whose buildNumber you have already used. It is the most common first-submission failure and the error message does not say so plainly.
What actually gets you rejected, in order of how often I have seen it: a login screen with no demo account in the review notes; asking for a permission without explaining why in the usage string; a privacy policy URL that 404s; and an app that is essentially a website in a wrapper. The first three are ten-minute fixes. The fourth is not.
Delivering app projects for clients
The commercial side, which determines whether the work is worth doing.
Scope in features, not screens. Clients count screens; effort lives in logic. Authentication, payments, push notifications, offline sync, and file upload are each substantial pieces of work regardless of how few screens they occupy. Price accordingly.
Write down what's excluded. Apps attract assumed inclusions: tablet layouts, multiple languages, dark mode, accessibility beyond the basics, admin panels, analytics dashboards, and app store assets and copy. Each is real work. Naming them in the proposal either prices them or removes them.
Account for the ongoing burden. Apps are not one-time deliverables. Both platforms mandate SDK updates on a schedule, OS releases break things annually, and certificates expire. A client who thinks they're buying a finished artefact will be surprised at month fourteen. Say this during the sales conversation and offer a maintenance arrangement — it's genuinely necessary and it's recurring revenue.
Deliver in weekly increments with a working build each time. Clients cannot evaluate a description; they can evaluate an app on their phone. Get a build into their hands in week one, however incomplete. It surfaces misunderstandings while they're cheap to fix and it does more for their confidence than any status report.
Get the store accounts created at kickoff. Apple Developer enrolment can take days and requires a D-U-N-S number for organisations. This is a routine cause of launch delay, it's entirely on the client's side, and it's your job to have flagged it in week one.
Hand over properly. Repository access, credentials, environment configuration, build instructions, and a written description of the architecture. Clients renew with people who leave things in good order, and the alternative — being the only person who can touch the codebase — is a weaker commercial position than it appears.