All topics
Mobile · Learning hub

Expo notes for developers

Master Expo with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — Expo quizMore Mobile notes
Expo

Expo Essentials

Expo Essentials Managed Workflow vs Bare Workflow Expo is a framework and set of tools built on top of React Native. It gives you a managed toolchain — a dev cl

Expo Essentials

Managed Workflow vs Bare Workflow

Expo is a framework and set of tools built on top of React Native. It gives you a managed toolchain — a dev client, cloud builds (EAS Build), OTA JavaScript updates (EAS Update), and a large library of pre-built native modules (expo-camera, expo-notifications, expo-location, etc.) — so most apps never need to touch native iOS/Android code directly.

In the managed workflow there is no ios/ or android/ folder in your repo. Native configuration lives declaratively in app.json or app.config.ts, and Expo generates the native projects for you at build time in a process called 'prebuild'. You can eject to the bare workflow at any point — running npx expo prebuild generates the native folders and hands you full control, while most expo-* packages still work via autolinking.

Expo Go (the App Store/Play Store client) can only run apps that use built-in Expo SDK modules — it cannot load custom native code or most third-party native libraries. As soon as you add a library with native code that isn't part of the Expo SDK, you need a custom development build (npx expo run:ios / run:android, or an EAS-built dev client) instead of Expo Go.

// app.config.ts — dynamic config, preferred over static app.json once you need env-based values
import { ExpoConfig, ConfigContext } from 'expo/config'

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: 'MyApp',
  slug: 'my-app',
  version: '1.4.0',
  orientation: 'portrait',
  scheme: 'myapp',
  icon: './assets/icon.png',
  runtimeVersion: { policy: 'appVersion' },
  ios: {
    bundleIdentifier: 'com.mycompany.myapp',
    supportsTablet: true,
    infoPlist: {
      NSCameraUsageDescription: 'We use the camera to scan receipts.',
    },
  },
  android: {
    package: 'com.mycompany.myapp',
    adaptiveIcon: {
      foregroundImage: './assets/adaptive-icon.png',
      backgroundColor: '#0B1220',
    },
    permissions: ['CAMERA'],
  },
  extra: {
    apiUrl: process.env.EXPO_PUBLIC_API_URL,
    eas: { projectId: '3f1a2b4c-0000-1111-2222-abcdef123456' },
  },
  plugins: ['expo-router', 'expo-camera', 'expo-notifications'],
})

Expo Router: File-Based Navigation

expo-router turns the app/ directory into your navigation tree, the same way Next.js turns a pages/ or app/ directory into routes. Every file exports a screen component; a _layout.tsx in a folder defines how its siblings are wrapped (Stack, Tabs, Drawer). Parenthesized folders like (tabs) group routes without adding a path segment, and square-bracket files like [id].tsx become dynamic segments.

Because routes are just files, deep linking and web URLs come for free — app/product/[id].tsx is reachable at /product/42 on both native (as a deep link) and web, with no separate linking config to maintain. Use useLocalSearchParams to read route params and router.push or <Link> to navigate.

// app/_layout.tsx — root layout, wraps every route
import { Stack } from 'expo-router'

export default function RootLayout() {
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="(tabs)" />
      <Stack.Screen name="product/[id]" options={{ headerShown: true, title: 'Product' }} />
    </Stack>
  )
}

// app/(tabs)/_layout.tsx — tab navigator for the (tabs) group
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'

export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{ title: 'Home', tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} /> }}
      />
      <Tabs.Screen name="cart" options={{ title: 'Cart' }} />
    </Tabs>
  )
}

// app/product/[id].tsx — dynamic route, reachable natively and at /product/42 on web
import { useLocalSearchParams, useRouter } from 'expo-router'
import { View, Text, Button } from 'react-native'

export default function ProductScreen() {
  const { id } = useLocalSearchParams<{ id: string }>()
  const router = useRouter()

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text>Product #{id}</Text>
      <Button title="Back to cart" onPress={() => router.push('/(tabs)/cart')} />
    </View>
  )
}

Config Plugins & Native Modules

Config plugins let a package configure the native iOS/Android projects Expo generates — adding permissions, entitlements, Gradle changes, or Info.plist keys — without you hand-editing native files. Most expo-* and popular third-party packages ship a plugin; you enable it by adding it to the plugins array in app.config.ts, optionally with options.

When no existing plugin covers what you need, you write a small one using withXcodeProject, withAndroidManifest, withInfoPlist, etc. from @expo/config-plugins. Plugins run during expo prebuild (which EAS Build runs automatically before compiling), so changes only take effect after a new prebuild/build — not with an OTA JS update.

// plugins/withCustomUrlScheme.ts — minimal custom config plugin
import { ConfigPlugin, withInfoPlist } from '@expo/config-plugins'

const withCustomUrlScheme: ConfigPlugin<{ scheme: string }> = (config, { scheme }) => {
  return withInfoPlist(config, (cfg) => {
    cfg.modResults.CFBundleURLTypes = [
      ...(cfg.modResults.CFBundleURLTypes ?? []),
      { CFBundleURLSchemes: [scheme] },
    ]
    return cfg
  })
}

export default withCustomUrlScheme

// app.config.ts
export default {
  // ...
  plugins: [
    'expo-router',
    ['expo-camera', { cameraPermission: 'Allow $(PRODUCT_NAME) to access your camera' }],
    ['./plugins/withCustomUrlScheme', { scheme: 'myapp' }],
  ],
}

EAS Build, Submit & Update

EAS (Expo Application Services) Build compiles your native app in the cloud, so you don't need Xcode or Android Studio installed to produce an .ipa or .aab. Build profiles in eas.json define distinct configurations — a 'development' profile that includes the dev client, a 'preview' profile for internal testing, and a 'production' profile ready for the stores.

EAS Submit uploads a finished build straight to App Store Connect or the Play Console. EAS Update pushes JavaScript/asset changes over the air to installed apps without going through app store review — but it can only update JS, not native code, and updates are gated by runtimeVersion so an incompatible native build never receives a JS bundle it can't run.

{
  "cli": { "version": ">= 12.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": true }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "autoIncrement": true,
      "channel": "production",
      "env": { "EXPO_PUBLIC_API_URL": "https://api.myapp.com" }
    }
  },
  "submit": {
    "production": {
      "ios": { "appleId": "you@company.com", "ascAppId": "1234567890" },
      "android": { "serviceAccountKeyPath": "./secrets/play-service-account.json", "track": "production" }
    }
  }
}

// CLI:
// eas build --profile production --platform all
// eas submit --profile production --platform ios
// eas update --branch production --message "Fix cart total rounding"

Common Pitfalls

  • Expo Go is for quick prototyping only — the moment you add a library with custom native code, switch to a development build (npx expo run:ios/run:android locally, or an EAS dev-client build).

  • Client-exposed env vars must be prefixed EXPO_PUBLIC_ and are baked into the JS bundle at build time — never put secrets there; keep server secrets out of the client entirely.

  • app.config.ts/.js always wins over a static app.json if both exist — pick one source of truth to avoid confusing merges.

  • OTA updates via EAS Update only ship JS/asset changes. Adding a native dependency, a new permission, or a config plugin requires a fresh native build — an OTA update cannot deliver that.

  • Run npx expo-doctor before a release build — it catches version mismatches between the Expo SDK, React Native, and installed packages that would otherwise fail cloud builds.

  • Pin your runtimeVersion policy deliberately (appVersion or sdkVersion) — mismatched runtime versions between an OTA update and an installed build silently prevent the update from being applied.

  • Test camera, location, push notifications, and other hardware-backed APIs on a real device or a proper dev build — the iOS Simulator and some Expo Go paths behave differently or aren't supported at all.

Keep your Expo knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever