← All posts
·17 min read

Claude Code with Expo: Router, Config Plugins, and EAS Build

Claude CodeExpoReact NativeWorkflow
Claude Code with Expo: Router, Config Plugins, and EAS Build

Why Expo needs its own CLAUDE.md, separate from plain React Native

Expo is React Native, but the two workflows diverge quickly when it comes to native code. A CLAUDE.md written for a bare React Native CLI project will get you some of the way through an Expo project and then quietly generate wrong things. The divergences are concrete enough that Expo warrants its own set of rules.

The first divergence is configuration. Plain React Native projects configure native targets directly in Xcode and Android Studio. Expo managed and bare workflow projects configure native targets through app.config.ts and config plugins. If Claude thinks it is working on a standard RN project, it will suggest editing ios/AppDelegate.swift or android/MainActivity.kt directly. On a managed Expo project those edits will be overwritten the next time you run npx expo prebuild. The rule "never manually edit generated native files; use config plugins" has to be stated explicitly.

The second divergence is the dev runtime. Plain React Native uses the Metro bundler and npx react-native run-ios or npx react-native run-android. Expo projects have three runtimes: Expo Go (no custom native code), dev client (custom native build, full hot reload), and prebuild (generates the native project for a production-style build). Claude needs to know which you are using before it can tell you how to install a new native module correctly.

The third divergence is the build pipeline. Expo projects use EAS Build for cloud builds and EAS Update for over-the-air JavaScript updates. The build profiles, channel names, and update rollout strategy are all Expo-specific concepts Claude will not fill in correctly without context.

The Claude Code with React Native post covers the shared baseline. This post adds the Expo-specific layer on top: the CLAUDE.md rules that are unique to Expo Router v4, app.config.ts, config plugins, EAS Build, and OTA updates. If you have not installed Claude Code yet, the Claude Code setup guide is the right starting point.

The Expo CLAUDE.md template

This template is for Expo SDK 54 with Expo Router v4, the versions current in mid-2026. If you are on an older SDK, the broad structure applies but module names and some APIs will differ.

# Expo project rules

## Stack
- Expo SDK 54 (managed workflow, using config plugins for native access)
- Expo Router v4 (file-based routing, typed routes enabled)
- React Native 0.76.x (Hermes JS engine)
- TypeScript 5.x, strict mode
- State: Zustand 5.x
- Styling: NativeWind v4 (Tailwind for React Native)
- Forms: React Hook Form + Zod

## Project structure
- app/                  Expo Router file-based routes
  - (tabs)/             Tab bar group
  - (auth)/             Auth stack group
  - _layout.tsx         Root layout, providers, font loading, Slot
  - index.tsx           Entry route (/)
- components/           Shared UI (PascalCase.tsx)
- hooks/                Custom hooks (useCamelCase.ts)
- stores/               Zustand stores
- lib/                  API clients, utilities
- assets/               Images, fonts, icons
- app.config.ts         Dynamic Expo config (canonical, not app.json)
- eas.json              EAS Build profile config

## Runtime decision
- dev: Expo Dev Client (custom native build), NOT Expo Go
  - Run: `npx expo start --dev-client`
  - Expo Go is only for quick prototyping with SDK-bundled modules
  - Use dev client any time a native module outside the Expo SDK is installed
- prebuild: `npx expo prebuild --clean` regenerates ios/ and android/
  - NEVER commit manually edited native files when on managed workflow
  - Config plugins own all native modifications

## File-based routing conventions (Expo Router v4)
- Layouts: _layout.tsx (Stack, Tabs, Drawer)
- Route groups: (group-name)/, affects layout, not URL
- Dynamic segments: [id].tsx, [slug].tsx
- Catch-all: [...rest].tsx
- Index routes: index.tsx
- Modal routes: modal.tsx (in a Stack with presentation="modal")
- Static pages: +not-found.tsx, +html.tsx
- API routes: app/api/ (Expo Router web server, +api.ts suffix)

## app.config.ts (canonical config file)
- Use app.config.ts NOT app.json, dynamic config allows process.env access
- Extra field pattern:
  extra: {
    apiUrl: process.env.EXPO_PUBLIC_API_URL,
    apiVersion: "v2",
  },
  eas: { projectId: "your-eas-project-id" }
- All expo-constants access through Constants.expoConfig.extra
- NEVER put secrets in app.config.ts, extra is bundled into the JS and readable
- Secrets belong in EAS secrets (build-time, not runtime accessible to JS)

## Environment variables
- EXPO_PUBLIC_* variables: bundled into JS via expo-constants, readable at runtime
  - Access: Constants.expoConfig?.extra?.apiUrl
  - Safe for: API URLs, feature flags, public keys (publishable Stripe key, etc.)
  - NOT safe for: secret keys, private tokens, anything not meant for users
- EAS secrets: set via `eas secret:create`, available as env vars at build time
  - For: signing keys, server-side tokens used in EAS Build scripts only
  - NOT accessible to app JS code at runtime
- Local .env.local: read by metro during `expo start` for EXPO_PUBLIC_* vars
  - Never commit .env.local

## Config plugins (native module setup)
- Every native module that touches native code needs a config plugin
- Config plugins run during `expo prebuild` and modify ios/ and android/
- Pattern for third-party modules: check module's README for withExpoConfig usage
- Canonical plugin usage in app.config.ts:
  plugins: [
    "expo-router",
    "expo-camera",
    ["expo-location", { locationAlwaysAndWhenInUsePermission: "..." }],
    ["expo-build-properties", { ios: { deploymentTarget: "17.0" } }],
  ]
- NEVER patch native files directly; write a config plugin or use withMod

## Hermes JS engine
- Hermes is default for SDK 54 (iOS and Android)
- Hermes does not support all V8/SpiderMonkey APIs
- AVOID: Proxy, Reflect, WeakRef if targeting old Hermes versions
- USE: hermes-parser for static analysis in CI (not runtime)
- Sourcemaps: Hermes produces different sourcemap format, EAS handles this in cloud builds

## EAS Build profiles (eas.json)
- development: builds dev client, connects to local Metro
  - distribution: internal
  - developmentClient: true
  - channel: development
- preview: production-like build for TestFlight/internal testing
  - distribution: internal
  - channel: preview
- production: App Store / Play Store
  - distribution: store
  - channel: production
  - autoIncrement: true

## EAS Update (OTA)
- Channels map: development → preview → production
- Standard deploy: `eas update --branch <channel> --message "..."`
- NEVER push to production channel without testing on preview first
- Critical JS fixes: branch pattern `hotfix/<ticket>`, merge to production channel only after preview sign-off
- Native-touching changes (new config plugin, SDK bump, new native module) REQUIRE a new binary, OTA cannot deliver native changes

## Hard rules
- NEVER edit ios/ or android/ files directly on managed workflow
- NEVER put secrets in app.config.ts extra or any EXPO_PUBLIC_ variable
- NEVER use Expo Go for dev once a native module outside the SDK is installed
- NEVER push an OTA update to the production channel without preview channel sign-off
- ALWAYS specify the channel flag on eas update, never let it default
- ALWAYS run npx expo-doctor after adding a new dependency
- ALWAYS verify config plugin is present in plugins array after installing a native module

That is 45 lines of declared context that prevents most of the wrong outputs Claude produces on Expo projects. Two rules anchor the rest.

The "never edit native files directly" rule has the biggest surface area. Config plugins are Expo's mechanism for touching native code without breaking managed workflow. If Claude edits ios/Podfile or android/build.gradle directly, those changes are gone the next time expo prebuild --clean runs, and the app will either fail to compile or silently miss the native configuration. Stating the rule explicitly prevents Claude from reaching for the native files. It will instead look for a config plugin or ask you to write one.

The "never put secrets in app.config.ts extra" rule matters because the extra field is included in the app's JavaScript bundle. It is not a build-time variable. A secret key in extra is a secret key anyone can extract from your .apk or .ipa file. The rule redirects Claude toward EAS secrets for anything that should not be user-readable.

Expo Router v4 file conventions

Expo Router v4 uses a file-based routing system similar in concept to Next.js App Router, but with React Navigation under the hood and mobile-specific primitives. Claude knows the basic conventions but needs your specific layout structure to generate routes that compose correctly.

The core conventions are: _layout.tsx at each directory level defines the navigator for that scope, route groups in parentheses like (tabs)/ affect layout without changing the URL, and dynamic segments like [id].tsx generate a screen that receives useLocalSearchParams() for the parameter.

// app/(tabs)/_layout.tsx, Tab navigator root
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        headerShown: false,
        tabBarActiveTintColor: "#5070f7",
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: "Home",
          tabBarIcon: ({ color }) => (
            <Ionicons name="home-outline" size={24} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: "Profile",
          tabBarIcon: ({ color }) => (
            <Ionicons name="person-outline" size={24} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}
// app/(tabs)/profile/[userId].tsx, Dynamic screen
import { useLocalSearchParams, Stack } from "expo-router";

export default function UserProfileScreen() {
  const { userId } = useLocalSearchParams<{ userId: string }>();

  return (
    <>
      <Stack.Screen options={{ title: `Profile: ${userId}` }} />
      {/* screen content */}
    </>
  );
}

The typed routes feature is worth enabling from day one. With "typedRoutes": true in your Expo config, the href prop on <Link> and the path argument to router.push() become statically typed against your actual file structure. Claude will generate type-correct navigation calls when the feature is declared, and untyped string literals when it is not.

Add to app.config.ts:

experiments: {
  typedRoutes: true,
},

One pattern worth stating explicitly in your CLAUDE.md navigation section: the difference between router.push() and router.replace(). Push adds to the stack and shows a back button. Replace swaps the current screen and removes it from the stack. Claude defaults to push even in flows where replace is correct, like an auth redirect that should not allow the user to go back to the login screen.

app.config.ts and config plugins

app.config.ts is the canonical config file for Expo projects that need dynamic values or config plugin declarations. The .ts extension matters: it runs at build time with Node.js, so you can read environment variables, compute values, and import from other config files.

// app.config.ts
import type { ExpoConfig, ConfigContext } from "expo/config";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: "MyApp",
  slug: "myapp",
  version: "1.0.0",
  orientation: "portrait",
  icon: "./assets/images/icon.png",
  scheme: "myapp",
  userInterfaceStyle: "automatic",
  splash: {
    image: "./assets/images/splash.png",
    resizeMode: "contain",
    backgroundColor: "#ffffff",
  },
  ios: {
    supportsTablet: true,
    bundleIdentifier: "com.yourco.myapp",
    buildNumber: "1",
  },
  android: {
    adaptiveIcon: {
      foregroundImage: "./assets/images/adaptive-icon.png",
      backgroundColor: "#ffffff",
    },
    package: "com.yourco.myapp",
    versionCode: 1,
  },
  plugins: [
    "expo-router",
    ["expo-camera", { cameraPermission: "Allow MyApp to access your camera." }],
    [
      "expo-location",
      {
        locationAlwaysAndWhenInUsePermission:
          "Allow MyApp to use your location.",
      },
    ],
    [
      "expo-notifications",
      {
        icon: "./assets/images/notification-icon.png",
        color: "#ffffff",
      },
    ],
    [
      "expo-build-properties",
      {
        ios: { deploymentTarget: "17.0" },
        android: { compileSdkVersion: 35, targetSdkVersion: 35 },
      },
    ],
  ],
  extra: {
    apiUrl: process.env.EXPO_PUBLIC_API_URL ?? "https://api.yourapp.com",
    eas: {
      projectId: "your-eas-project-id",
    },
  },
});

Config plugins are functions that receive the Expo config and return a modified version. For most popular native modules, the plugin ships with the module and you just list it in the plugins array. Occasionally you need to write your own, for example when integrating a third-party SDK that predates Expo's plugin ecosystem.

A minimal config plugin for adding a custom framework to the iOS target:

// plugins/withMyNativeSDK.ts
import type { ConfigPlugin } from "expo/config-plugins";
import { withXcodeProject } from "expo/config-plugins";

const withMyNativeSDK: ConfigPlugin = (config) => {
  return withXcodeProject(config, (exportedConfig) => {
    const project = exportedConfig.modResults;
    // Modify the Xcode project object here
    // e.g., project.addFramework("MySDK.framework")
    return exportedConfig;
  });
};

export default withMyNativeSDK;
// app.config.ts, reference the local plugin
import withMyNativeSDK from "./plugins/withMyNativeSDK";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: ["expo-router", withMyNativeSDK],
});

The critical workflow is: add the plugin to app.config.ts, then run npx expo prebuild to regenerate the native projects, then rebuild the dev client. Claude will skip one of these steps without the explicit sequence in CLAUDE.md. Adding the pattern "after any config plugin change: prebuild, then rebuild dev client" prevents the scenario where you add a plugin and then spend an hour wondering why the native feature still does not work.

EAS Build profiles and OTA update channels

EAS Build and EAS Update work together through channels. A channel is a named delivery lane. Each build profile writes to a channel. EAS Update delivers JavaScript-only updates to devices running a build from that channel. The relationship is worth making explicit because Claude sometimes conflates build profiles and update branches.

// eas.json
{
  "cli": {
    "version": ">= 12.0.0",
    "appVersionSource": "local"
  },
  "build": {
    "development": {
      "distribution": "internal",
      "developmentClient": true,
      "ios": {
        "simulator": true
      },
      "env": {
        "APP_ENV": "development"
      },
      "channel": "development"
    },
    "preview": {
      "distribution": "internal",
      "ios": {
        "simulator": false
      },
      "env": {
        "APP_ENV": "preview"
      },
      "channel": "preview"
    },
    "production": {
      "distribution": "store",
      "autoIncrement": true,
      "env": {
        "APP_ENV": "production"
      },
      "channel": "production"
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "your@apple.id",
        "ascAppId": "1234567890",
        "appleTeamId": "TEAMID"
      },
      "android": {
        "serviceAccountKeyPath": "./google-services-key.json",
        "track": "production"
      }
    }
  }
}

The distinction between what OTA updates can deliver and what requires a new binary is the most important concept in this file. An EAS Update delivers a new JavaScript bundle to existing app installs. It can change every line of React Native JavaScript code. It cannot deliver new native modules, updated native SDK versions, new config plugin outputs, or changes to the native entry point. If you install expo-camera for the first time, that is a new binary. If you fix a bug in your camera screen's JavaScript, that is an OTA update.

Add an explicit section to your CLAUDE.md for this boundary:

## OTA vs binary change, decision guide

Requires new binary (new EAS Build):
- Adding any native module (new entry in plugins array)
- Bumping expo-build-properties targets
- Changing app.json/app.config.ts native fields (bundleIdentifier, scheme, permissions)
- Upgrading Expo SDK major version
- Adding or updating config plugins that touch native files

Safe for OTA (EAS Update only):
- Bug fixes in JS/TSX code
- UI changes, new screens added via Expo Router file routing
- Updated API calls, data fetching logic
- New dependencies that are pure JS (no native code)
- String updates, copy changes, analytics events

Command: `eas update --branch production --message "fix: checkout crash on Android"`
Always specify --branch. Never let it default.

When Claude generates an install command for a new package, it will often follow it immediately with "then run npx expo start." The CLAUDE.md rule prompts it to add npx expo-doctor and check whether the new package requires a prebuild before restarting dev.

For the broader environment variable handling that feeds into app.config.ts, the patterns in Claude Code with environment variables are directly applicable. For managing secrets at the build layer, the Claude Code permissions post covers the hook and environment gating patterns that complement EAS secrets.

Expo Go vs dev client vs prebuild: the decision guide

This is the question Expo developers ask most when starting a new project, and Claude needs to know your answer before it can tell you how to add a native module or run your app.

Expo Go is the Expo app from the App Store or Play Store. It contains a fixed set of native modules from the Expo SDK. You can run your JavaScript code in Expo Go without any build step. It is useful for prototyping, for developers joining a project who do not have Xcode or Android Studio, and for any app that stays within the built-in SDK modules. The limitation is hard: if you install any native module that Expo Go does not bundle, it will fail silently or crash.

Dev client is a development-mode binary you build once with EAS Build (or locally with npx expo run:ios / npx expo run:android). It includes your app's full native dependency set and supports hot reloading during development. Use a dev client any time your app has a native module outside the standard SDK, which in practice means any real production app. The workflow overhead is a one-time build of the dev client. After that, day-to-day development is exactly as fast as Expo Go.

Prebuild is the command that generates the ios/ and android/ directories from your config. Running npx expo prebuild produces the native project. Running npx expo prebuild --clean deletes and regenerates it from scratch. You run prebuild when you want to inspect the generated native code, when you are debugging a config plugin, or when you are transitioning from managed to bare workflow. The output of prebuild is what EAS Build compiles.

## Runtime decision matrix (add to CLAUDE.md)

| Scenario | Use |
|---|---|
| Prototyping, no custom native modules | Expo Go |
| Any native module outside Expo SDK | Dev Client |
| Debugging a config plugin output | Prebuild + inspect ios/ or android/ |
| Production build for store | EAS Build (production profile) |
| Preview build for TestFlight | EAS Build (preview profile) |
| Testing OTA update delivery | eas update --branch preview |
| CI/CD automated build | EAS Build via eas build --non-interactive |

## Adding a native module checklist (add to CLAUDE.md)
1. `npm install <module>` or `npx expo install <module>`
2. Check module README for its config plugin usage
3. Add plugin entry to app.config.ts plugins array
4. Run `npx expo-doctor` to verify compatibility
5. Run `npx expo prebuild` to generate updated native projects
6. Rebuild dev client: `eas build --profile development --platform ios`
7. Restart Metro: `npx expo start --dev-client`

The npx expo install command is worth noting: it installs the version of a package that is compatible with your current SDK, rather than the latest version from npm. Claude sometimes uses npm install instead, which can pull in an incompatible version of a module. Adding "use npx expo install for all Expo and React Native packages" to your hard rules section gets Claude to use the right installer consistently.

Common failure modes and how CLAUDE.md prevents them

These are the failures that come up repeatedly in Expo projects when Claude works without sufficient context. Each one maps to a rule in the template above.

Native file edits that get overwritten. Claude edits ios/Podfile to add a CocoaPods dependency. The edit works. Two weeks later someone runs npx expo prebuild --clean and the change disappears. The prebuild regenerates from config, not from the current state of ios/. The fix is a config plugin or expo-build-properties. The CLAUDE.md rule "never edit ios/ or android/ on managed workflow" prevents this. If Claude hits a case where a config plugin does not expose the setting you need, it will tell you rather than editing the native file directly.

Secrets in app.config.ts extra. Claude scaffolds a config that puts an API key in extra because it reads that extra is how you pass config to the app. The key ends up in the JavaScript bundle, extractable from any .apk file. The distinction to make explicit: EXPO_PUBLIC_* variables and extra are for public runtime config, EAS secrets are for private build-time config. The CLAUDE.md rule handles this in one line.

Wrong dev workflow for installed native module. A developer installs react-native-purchases for in-app purchases, then runs npx expo start and scans the QR code in Expo Go. The app crashes because Expo Go does not bundle the RevenueCat native SDK. The rule "use dev client any time a native module outside the SDK is installed" gives Claude the correct run command: npx expo start --dev-client, with the prerequisite that the dev client has been built with EAS Build.

OTA update pushed with a native change. A developer adds expo-sensors to read the accelerometer, ships a new binary via EAS Build, then pushes an OTA update that includes the JavaScript calls to expo-sensors. Users on the old binary receive the OTA update and the expo-sensors import throws because the native module is not present. The fix is to ensure all users are on the new binary before pushing the OTA. The CLAUDE.md channel structure and the "native changes require a new binary" rule give Claude the context to flag this risk when generating the update command.

Missing npx expo-doctor step. A new package is installed that has a peer dependency conflict with the current SDK. The conflict does not surface until a later build. expo-doctor catches it immediately after install. Adding it to the "after install" checklist in CLAUDE.md gets Claude to include the step in its instructions without being prompted.

These failure modes share a root cause: Claude generating plausible React Native patterns that are correct for bare workflow and wrong for Expo managed workflow. The CLAUDE.md template is the boundary between those two worlds.

Putting it together: a session workflow

A working session with Claude Code on an Expo project follows a predictable pattern once the CLAUDE.md is in place. Claude reads the config at session start, knows you are on SDK 54 with Expo Router v4 and a dev client workflow, and adjusts its output accordingly.

For adding a new screen, Claude generates the file in the right location under app/, uses useLocalSearchParams for any dynamic segment, and adds the screen to the appropriate _layout.tsx. It does not suggest installing react-navigation separately because the CLAUDE.md declares Expo Router as the navigation layer.

For adding a new native module, Claude installs with npx expo install, checks the plugin docs, adds the plugin entry to app.config.ts, and outputs the full checklist: doctor, prebuild, rebuild dev client. It does not stop at npm install.

For an OTA update, Claude generates the eas update command with an explicit --branch flag, and if the changeset includes a new native module, it flags that the update cannot be delivered over-the-air and a new binary is required.

For environment variables, Claude puts public runtime config in EXPO_PUBLIC_* and accesses it via Constants.expoConfig?.extra. It does not put API secrets in extra. It reminds you that the dev client needs to be rebuilt if the config changes in ways that affect native behavior.

The broader patterns for structuring Claude's context around hooks, custom agents, and memory systems are covered in Claude Code memory systems and Claude Code custom agents. For the Expo-specific Skills that Expo Inc. publishes for AI agents, the Claude Code Skills tutorial covers how they compose with your own CLAUDE.md rules.

The gap between "Claude knows Expo" and "Claude writes production-ready Expo code" is the CLAUDE.md. Without it, Claude defaults to patterns that are correct somewhere in the React Native ecosystem and wrong for your specific workflow. With it, Claude generates config-plugin-based native module setup, Expo Router file conventions, EAS-correct build and update commands, and channel-aware OTA logic by default, on every prompt, without reminders.

Claudify includes an Expo-specific CLAUDE.md template pre-configured for SDK 54, Expo Router v4, and the EAS Build profiles and OTA channel structure in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir