---
title: "Build a mobile app with Expo"
description: "Generate app.json, the iOS privacy manifest and the Play Data safety form from main.yaml, keep secrets in Keychain, attest the device and send push through notify."
section: "guides"
status: "Planned for 1.1"
spec: "expo-adapter"
canonical: "https://mainsdk.dev/docs/guides/build-a-mobile-app-with-expo"
updated: "2026-10-28"
---

# Build a mobile app with Expo

> Generate app.json, the iOS privacy manifest and the Play Data safety form from main.yaml, keep secrets in Keychain, attest the device and send push through notify.

Status: Planned for 1.1 · Canonical: https://mainsdk.dev/docs/guides/build-a-mobile-app-with-expo · Updated: 2026-10-28

You will point an Expo app at MainSDK, let `mainsdk sync` write the native files, and add the device-side rules. It takes about 30 minutes.

## You need

- A MainSDK app and an Expo app. Push and attestation take a real device.

## 1. Declare the mobile target

```yaml title="main.yaml"
targets:
  mobile:
    framework: expo
    platforms: [ios, android]
    bundleId: dev.acme.orders
    signing:
      teamId: ABCDE12345
      androidSha256: ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
notify:
  channels: [push]
```

Run `mainsdk sync`. It writes `app.json`, `mobile/PrivacyInfo.xcprivacy`, `mobile/play-data-safety.csv` and the two association files under `public/.well-known/`. `mainsdk explain config --target mobile` shows where each `app.json` value came from.

## 2. Keep secrets in Keychain and Keystore

```ts title="app/session.ts"
import { DeviceVault, type SecureStorePort } from "mainsdk/expo";

// In the app: import * as SecureStore from "expo-secure-store"; it has the three calls SecureStorePort names.
declare const SecureStore: SecureStorePort;
declare const token: string;   // the refresh token from sign-in

export const vault = new DeviceVault(SecureStore);
await vault.saveRefreshToken(token);
```

Wrap AsyncStorage with `guardedPlainStore` so a write whose key names a secret fails with `MAIN-MOBILE-0002`. `mainsdk check` raises `MAIN-MOBILE-0003` for a secret written to AsyncStorage and `MAIN-MOBILE-0004` for a raw `fetch`.

## 3. Attest the device and decide consent on it

On the server, build an `Attestation` with a verifier for App Attest and Play Integrity, call `check` before an action marked `attest: required`, and a request with no valid token fails with `MAIN-AUTH-0100`. On the phone, a `DeviceGate` decides `analytics.track` and `storage.write` with the same policy engine as the server, so an EU device sends nothing until the person agrees.

## 4. Send push through notify

`registerPush` asks permission and stores the token only when the person says yes. The `expoPush`, `fcmPush` and `apnsPush` drivers carry ids and no message text, and `guardPush` drops any field your labels mark as personal. Quiet hours and the stop action are `notify`'s.

## Check that it works

- `mainsdk check` passes, and fails with `MAIN-NET-0050` when `Info.plist` sets `NSAllowsArbitraryLoads`.
- Send a push at 23:00 in the person's time zone: it is held until 07:00.
- The Expo, FCM and APNs request shapes follow each provider's documentation; send one push through each sandbox before launch.

## Next

- [Sign in on a phone](https://mainsdk.dev/docs/guides/sign-in-on-a-phone)
