React Native with Expo skill for Claude Code

Dev builds, Expo Router and OTA updates that are safe. Claude works in Expo projects the way the toolchain expects: native libraries via npx expo install, native settings through app config and plugins instead of hand-edited ios and android folders, development builds once custom native code appears, and OTA updates only to binaries with a matching runtime version. It checks safe areas, keyboard, back handling and permissions on both platforms.

Name
react-native-expo
Size
223 lines · ≈2,400 tokens
Files
1
License
MIT
Updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds and ships React Native apps with Expo — SDK upgrades, Expo Router file-based navigation, development builds vs. Expo Go, Continuous Native Generation with config plugins, EAS Build, Submit, and Update with runtime versions and channels, Expo Modules for native code, and iOS/Android platform differences (safe areas, keyboard, edge-to-edge, permissions). Use when creating or maintaining an Expo app, adding a native library, configuring app.json or eas.json, shipping an OTA update, or fixing something that works on one platform but not the other.

Try asking

  • Set up Expo Router with tabs and a protected sign-in flow
  • Ship this checkout fix as an OTA update without breaking older app versions
  • This screen's keyboard covers the input on Android but not on iOS

Use it when

  • Creating or upgrading an Expo app or SDK version
  • Adding a native library, permission or config plugin
  • Configuring eas.json, channels and runtime versions
  • Fixing behavior that differs between iOS and Android

Skip it when

How to check the result

expo-doctor is clean, the change works in a development or preview build on a physical iPhone and Android phone, and OTA updates apply after two cold starts on a matching build.

The common mistake it prevents

Publishing an OTA update after adding a native dependency, which crashes installed binaries that do not contain that native code.

~/.claude/skills/react-native-expo/SKILL.md

--- name: react-native-expo description: Builds and ships React Native apps with Expo — SDK upgrades, Expo Router file-based navigation, development builds vs. Expo Go, Continuous… license: MIT ---

A healthy Expo project installs native-compatible versions with npx expo install, regenerates native folders from config instead of hand-editing them, runs on development builds (not Expo Go) once it has any custom native code, ships OTA updates only to binaries with a matching runtime version, and is tested on a real iOS and a real Android device before release.

Before changing anything

npx expo --version && grep -E '"expo"|"react-native"|"expo-router"' package.json
npx expo-doctor                      # dependency, config, and native-version problems
npx expo install --check             # packages out of range for this SDK
ls ios android 2>/dev/null && git check-ignore ios android   # CNG (ignored) or committed native dirs?
cat eas.json 2>/dev/null; grep -n "runtimeVersion\|updates" app.json app.config.* 2>/dev/null
FindingMeaning
ios/ and android/ gitignoredContinuous Native Generation: native dirs are build output of app.json + plugins
Native dirs committedBare workflow: edit native files directly; prebuild would overwrite them
expo-dev-client installedTeam uses development builds
No runtimeVersionOTA updates are unsafe until one is set

This assumes SDK 54 or newer (New Architecture on by default; legacy architecture support ends after SDK 54). Read the SDK changelog before any upgrade.

Workflow for a feature or fix

- [ ] 1. Check whether the change is JS-only or touches native code/config
- [ ] 2. Install libraries with npx expo install (never bare npm install for native libs)
- [ ] 3. Native config via app.json/app.config.ts + config plugins, not hand edits (CNG)
- [ ] 4. Rebuild the development build if native code/config changed
- [ ] 5. Implement with platform differences handled explicitly
- [ ] 6. Test on iOS and Android devices (and a small screen)
- [ ] 7. Ship: OTA update if JS-only and runtime matches, otherwise new EAS build

Expo Go vs. development builds

UseWhen
Expo GoLearning, prototypes using only libraries bundled in Expo Go
Development build (expo-dev-client)Any custom native module, config plugin, push notifications, production parity
npx expo install expo-dev-client
eas build --profile development --platform ios      # or build locally:
npx expo run:ios                                    # requires Xcode; run:android requires Android Studio
npx expo start                                      # then open the dev build, not Expo Go

Rebuild the dev client whenever native dependencies, config plugins, or app.json native fields change. JS changes only need Metro reload.

Expo Router

app/
  _layout.tsx              # root Stack; providers; fonts/splash handling
  (tabs)/
    _layout.tsx            # <Tabs>
    index.tsx              # "/"
    settings.tsx           # "/settings"
  orders/
    [id].tsx               # "/orders/123"
  (auth)/sign-in.tsx
  +not-found.tsx
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
  const { isSignedIn } = useSession();
  return (
    <Stack>
      <Stack.Protected guard={isSignedIn}>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      </Stack.Protected>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="(auth)/sign-in" options={{ title: "Sign in" }} />
      </Stack.Protected>
    </Stack>
  );
}

// app/orders/[id].tsx
import { useLocalSearchParams, Stack } from "expo-router";
export default function Order() {
  const { id } = useLocalSearchParams<{ id: string }>();
  return <><Stack.Screen options={{ title: `Order ${id}` }} />{/* ... */}</>;
}
  • Navigate with <Link href="/orders/42"> or router.push({ pathname: "/orders/[id]", params: { id } }).
  • Every route is a deep link automatically (myapp://orders/42); set scheme in app.json. Universal/App Links setup lives in deep-links.
  • useLocalSearchParams for the current screen; useGlobalSearchParams re-renders on every route change — avoid it in heavy screens.
  • Stack.Protected (SDK 53+) replaces redirect-in-effect auth gating.

Configuration and CNG

// app.config.ts
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
  name: "Acme",
  slug: "acme",
  scheme: "acme",
  version: "1.4.0",
  runtimeVersion: { policy: "appVersion" },
  ios: { bundleIdentifier: "com.acme.app", infoPlist: { NSCameraUsageDescription: "Scan receipts to attach them to expenses." } },
  android: { package: "com.acme.app", permissions: ["android.permission.CAMERA"] },
  plugins: [
    "expo-router",
    ["expo-build-properties", { ios: { deploymentTarget: "16.4" } }],
  ],
};
export default config;
  • With CNG, never edit ios/ or android/ by hand; changes vanish on the next npx expo prebuild --clean. Write a config plugin for anything app.json cannot express.
  • Permission strings must explain the user benefit; vague strings get App Review rejections.
  • EXPO_PUBLIC_* env vars are inlined into the JS bundle at build time — public by definition. Secrets stay on your server; use EAS environment variables for build-time config.

EAS Build, Submit, Update

// eas.json
{
  "cli": { "appVersionSource": "remote" },
  "build": {
    "development": { "developmentClient": true, "distribution": "internal", "channel": "development" },
    "preview": { "distribution": "internal", "channel": "preview" },
    "production": { "autoIncrement": true, "channel": "production" }
  },
  "submit": { "production": {} }
}
eas build --platform all --profile production
eas submit --platform ios --latest
eas update --channel production --message "Fix totals rounding on checkout"

OTA update rules:

  • Updates replace JS and assets only. Any native change (new native library, SDK upgrade, permission, config plugin) requires a new store build.
  • runtimeVersion decides which binaries accept an update. policy: "appVersion" ties it to version; policy: "fingerprint" hashes native dependencies and config so a native change can never receive an incompatible update. Prefer fingerprint when in doubt.
  • Publish to preview first, test on an internal build, then production.
  • Roll back by republishing the last good update group (eas update:republish) rather than hot-fixing forward under pressure.
  • Store review rules still apply: OTA must not change the app’s primary purpose.

Native code

  • Look for an Expo SDK package or a well-maintained library with a config plugin before writing native code.
  • Write custom native modules with the Expo Modules API (Swift and Kotlin): npx create-expo-module@latest --local scaffolds modules/<name> with typed JS bindings.
  • Third-party libraries must support the New Architecture; check npx expo-doctor and the React Native Directory before adding one.

Platform differences checklist

AreaiOSAndroidApproach
Safe areas / insetsNotch, home indicatorStatus/nav bars; edge-to-edge is the default on recent Android and SDKsreact-native-safe-area-context useSafeAreaInsets; never hardcode 44/24 px
KeyboardOverlaps inputsResize behavior depends on window modeKeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} or react-native-keyboard-controller
Back navigationSwipe from edgeHardware/gesture backExpo Router handles stacks; confirm modals and unsaved-form prompts handle back
Shadowsshadow* propselevationboxShadow style (New Architecture) for one definition
PermissionsAsk once; afterwards only SettingsCan re-ask; “don’t ask again” stateExplain before prompting; deep link to settings on denial (Linking.openSettings())
FontsFont family nameFile nameLoad with expo-font; test both
Haptics, blur, date pickersNative feel differsUse platform-native components; test on device

File-level splits: Component.ios.tsx / Component.android.tsx when more than a few lines differ; Platform.select for values.

Preferred libraries

  • Images: expo-image (caching, placeholders). Lists: FlashList or FlatList with keyExtractor and memoized rows (see mobile-performance).
  • Animation: Reanimated (runs on the UI thread). Gestures: react-native-gesture-handler.
  • Secure tokens: expo-secure-store, never AsyncStorage. Local DB: expo-sqlite (see offline-sync).

Gotchas

  • npm install of a native library picks a version incompatible with the SDK; use npx expo install, then npx expo install --fix if expo-doctor complains.
  • Metro cache after config changes: npx expo start --clear.
  • Remote push notifications do not work in Expo Go on Android; use a development build.
  • An update published to a channel that no build listens to does nothing — check the build’s channel in eas.json.
  • Running prebuild --clean in a project with committed, hand-edited native dirs destroys those edits; confirm with the user before running it.

Verification

  1. npx expo-doctor reports no issues; npx tsc --noEmit and lint pass.
  2. Test on a physical iPhone and a physical Android device using a development or preview build — not only simulators and not Expo Go.
  3. For OTA: install the preview build, publish to preview, force-close and reopen twice; the update applies and Updates.updateId changes.
  4. Rotate, open the keyboard on every form, toggle dark mode, and use the largest font size on both platforms.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc