onebox

Guide 04 of 27

For your agent: .md · all guides

On this page
  1. What it is and what it costs
  2. Recommended layout
  3. Steps
  4. Where the values go
  5. Check it works
  6. Common errors

The Expo app

Runs on: your Mac.

This guide makes an Expo / React Native project ready for the rest of the setup: a fixed bundle identifier, an API URL per build profile, a development build, three EAS build profiles, and version numbers that EAS manages. It works for a new project and for one you already have.

What it is and what it costs

Expo is a framework and toolchain on top of React Native. EAS (Expo Application Services) is Expo’s build and submit service. The Expo account is free. Local builds on your Mac are free. Cloud builds on EAS have a free tier and paid plans; you do not need them for this setup. See expo-eas.md.

Before you start you need Xcode (xcode.md), an Apple Developer account (apple-developer.md) and a free Expo account (expo-eas.md, steps 1 to 3).

One repo per app, with the app and the backend side by side:

myapp/
  apps/
    mobile/            the Expo project (app.json, eas.json, package.json, src/ or app/)
    api/               the backend (see backend.md), with its Dockerfile
  docker-compose.yml   the production stack for the box
  .github/workflows/   ci.yml and deploy-api.yml
  scripts/             build and helper scripts
  app.config.js        a tripwire, see below
  package.json         workspace root (pnpm or npm workspaces)

With pnpm, list only apps/mobile in pnpm-workspace.yaml (a .NET API is not a pnpm package), and put node-linker=hoisted in .npmrc. Metro and CocoaPods do not follow pnpm’s symlinked node_modules.

Pin pnpm 10 with corepack use pnpm@10 at the repo root. It writes packageManager in package.json. pnpm 12 does not start through corepack yet, so do not take the newest version (tools.md). Put the Node major in .node-version (for example 24), and let CI read it with node-version-file: .node-version.

Put git worktree folders in .gitignore (for example .claude/worktrees/). eas build --local packs every file git does not ignore, and one build with worktrees inside the repo packed 34 GB. Do not add an .easignore: when it exists, EAS reads it instead of .gitignore.

ios/ and android/ inside apps/mobile are generated and belong in .gitignore. Expo writes them from your app config when you build (“continuous native generation”). Change native settings through the app config or a config plugin, never by editing ios/ by hand. The next build throws hand edits away.

The tripwire at the root

Expo and EAS read the app config from the directory you run them in. If you run eas build from the repo root by mistake, EAS can create an empty config there and sync it to Apple. An empty config does not declare Sign in with Apple, so EAS turns that capability off on your App ID. The build output says so in one line, and the build still looks successful. Sign-in then breaks for everyone.

Put this file at the repo root so the mistake fails loudly instead:

// app.config.js at the repo root. Not a config: a guard.
throw new Error("Run Expo/EAS commands from apps/mobile, not the repo root.");

Expo prefers app.config.js over app.json, so this file wins at the root. Root scripts should delegate, for example pnpm --filter mobile exec eas build --profile preview --platform ios.

Steps

1. Create or adopt the project

New project: the start:new-app skill does this step and the rest of this guide, plus the API and the checks. By hand:

mkdir -p myapp/apps && cd myapp/apps
npx create-expo-app@latest mobile

Inside an existing git repo, it asks whether to skip git init. Answer yes. The template also writes files next to the app:

npm run reset-project clears the example screens. It asks whether to move them to example/; answer no to delete them. It also deletes scripts/.

Existing project: move it into apps/mobile (or keep it at the root if there is no backend in the same repo; then skip the tripwire).

Link it to an Expo project once. This writes extra.eas.projectId into the app config. It needs the global eas CLI (expo-eas.md, steps 2 and 3):

cd apps/mobile
eas login
eas init

2. Choose the bundle identifier

The bundle identifier is your app’s permanent ID at Apple. Use reverse-DNS on a domain you control: com.example.myapp. Lowercase, no spaces.

It cannot change after the first upload to App Store Connect. A new bundle identifier means a new app with no reviews, no ratings and no users. Decide it now, write it down, and use the same value for:

Use the same value for android.package if you ever ship Android.

3. The app config

A static app.json is enough for most apps. The parts this setup needs:

{
  "expo": {
    "name": "My App",
    "slug": "myapp",
    "version": "1.0.0",
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "supportsTablet": false,
      "usesAppleSignIn": true,
      "infoPlist": {
        "ITSAppUsesNonExemptEncryption": false,
        "NSCameraUsageDescription": "My App uses the camera to scan a receipt and add it to your list."
      }
    },
    "plugins": ["expo-apple-authentication", "expo-secure-store"],
    "extra": { "eas": { "projectId": "set-by-eas-init" } }
  }
}

If you need values that change per build profile inside the config itself (a different app name for a dev variant, for example), use app.config.ts instead and read an APP_ENV variable that each profile in eas.json sets. Most apps do not need this. The API URL does not need it (next step).

4. The API URL, per build profile

No API of your own yet? Skip this step. Write no src/config/api.ts and no EXPO_PUBLIC_API_URL, and never a placeholder URL. Come back when the app gets an API.

The app reads its server address from EXPO_PUBLIC_API_URL. Metro inlines EXPO_PUBLIC_* variables into the JavaScript bundle when the build is made. The value is fixed in that binary forever.

That has a trap. If the URL lives only in a git-ignored .env.local on your laptop, a build made anywhere else gets an empty URL. The app still opens. It just never reaches the server, and nothing tells you.

So set the URL in eas.json, per profile (step 6). It is not a secret; it is visible to anyone who opens the app. For the development client on your Mac, set it in .env.local (step 6). Then read it in one place and refuse to guess:

// src/config/api.ts
// No default, not even in development: a default port can be another app's API.
export const API_URL = process.env.EXPO_PUBLIC_API_URL?.trim().replace(/\/+$/, "") ?? "";

if (!API_URL) {
  console.error("[config] EXPO_PUBLIC_API_URL is not set. Dev: .env.local. Builds: eas.json.");
} else if (!__DEV__ && !API_URL.startsWith("https://")) {
  console.error("[config] API URL must be https.");
}

Rules:

5. A development build, not Expo Go

Expo Go is a ready-made app from the App Store. It is fast for a first prototype. It stops working for this setup as soon as you add native modules:

A development build is your own app with Expo’s developer menu inside. Install the client and build it once:

npx expo install expo-dev-client
npx expo run:ios --device        # builds on this Mac and installs on the plugged-in iPhone

After that, npx expo start serves JavaScript changes to it, like Expo Go. Rebuild only when you add or change a native module or a config plugin. The ship-ios:expo-local-build skill covers the local build in detail.

6. eas.json: three profiles

{
  "cli": { "version": ">= 16.0.0", "appVersionSource": "remote" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.example.com",
        "EXPO_PUBLIC_REVENUECAT_IOS_KEY": "appl_public_sdk_key"
      }
    },
    "production": {
      "autoIncrement": true,
      "env": {
        "EXPO_PUBLIC_API_URL": "https://api.example.com",
        "EXPO_PUBLIC_REVENUECAT_IOS_KEY": "appl_public_sdk_key"
      }
    }
  },
  "submit": {
    "production": { "ios": { "ascAppId": "1234567890" } }
  }
}

EAS also has hosted environment variables with development, preview and production environments, selected by an environment field on each profile. Use them if you want the values out of the repo. expo-eas.md covers them. Keeping public values in the env block is simpler and works the same for local and cloud builds.

7. Version numbers

Apple uses two numbers:

FieldWho sees itExampleWho changes it
versionusers, in the store1.2.0you, in app.json, once per release
ios.buildNumberApple and you57EAS, on every production build

With "appVersionSource": "remote" EAS stores the build number on its servers. "autoIncrement": true on the production profile raises it on every build, local builds included. You never edit buildNumber by hand, and you never get “this build number was already used”.

One version can have many builds in TestFlight. After a version goes live in the store, raise version before the next upload.

If your backend has a minimum-version check, send the app’s version in a header on every request (for example X-App-Version, read from expo-constants). The server can then tell a user of an old build to update instead of failing in odd ways.

8. Keep secrets out of the app

Everything in the app bundle can be read by anyone who downloads the app. EXPO_PUBLIC_ means public. So does anything under extra in the app config: expo-constants ships it inside the app.

Fine in the app:

Never in the app:

If a secret key was ever in a build, rotate it. Removing it from the next build does not remove it from the builds people already have.

Check it: export the bundle and search it.

npx expo export --platform ios --output-dir /tmp/myapp-bundle
grep -raoE 'sk_(live|test)_[A-Za-z0-9]{8,}|sb_secret_[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{30,}|BEGIN [A-Z ]*PRIVATE KEY' /tmp/myapp-bundle | head

Expect no lines. ship-ios:app-store-ready runs a similar check on the config and the source.

9. Store tokens in the Keychain

Store the user’s session tokens with expo-secure-store (the iOS Keychain), not in AsyncStorage. AsyncStorage is a plain file in the app’s folder. It goes into device backups, and anyone with access to the files can read it. The Keychain is encrypted by the system.

import * as SecureStore from "expo-secure-store";

await SecureStore.setItemAsync("refreshToken", token);
const token = await SecureStore.getItemAsync("refreshToken");
await SecureStore.deleteItemAsync("refreshToken");   // on sign-out and account deletion

AsyncStorage is fine for settings that are not secret: a theme, a dismissed tip, a cache of public data. Run only one token refresh at a time; see the token part of backend.md (“Protect the API”).

10. No debug doors in release builds

Certificate pinning: usually not. Pinning makes the app trust only your certificate, even when the phone trusts others. It protects against an attacker who can install a trusted certificate on the user’s phone. For most apps that is not the risk. It has a real cost: Cloudflare renews its edge certificates on its own schedule, and a pin that no longer matches breaks the app for every user until they install an update. HTTPS with App Transport Security is enough. Consider pinning only for very sensitive data, with a backup pin and a plan to rotate.

Where the values go

ValueWhere
Bundle identifierios.bundleIdentifier in app.json; .onebox.json if a skill asks for it
API URLenv.EXPO_PUBLIC_API_URL on each profile in eas.json; for the dev client, .env.local in the app folder
Build modeexpo.buildMode in the onebox config: local (default) or cloud
Expo token (for scripts and CI)your secrets tool, referenced by expo.tokenRef
App Store Connect app IDsubmit.production.ios.ascAppId in eas.json

Check it works

cd apps/mobile
npx expo-doctor                                   # dependency and config problems
npx expo config --type public | grep -E 'bundleIdentifier|version'

Then, on your phone, in a preview build: open the screen that calls your API. It must load real data from https://api.example.com. On the box, the API log must show the request.

Common errors

Wrong or out of date? Fix it on GitHub.