SKILL DETAIL
expo-web-to-native
expo/skills/expo-web-to-native
This skill provides an end-to-end guide for migrating an existing web React app to a native iOS/Android app with Expo. It emphasizes incremental migration rather than rewriting: first stand up a native shell and run the entire web UI inside a DOM-component shell, then strangle screens to native in priority order. The skill covers assessing the codebase, scaffolding the Expo shell, bringing screens over as DOM components, nativizing screens with native components (preferring @expo/ui), wiring data fetching and auth, and finally shipping the app. It also includes mappings for web idioms (like the DOM, CSS, React Router, localStorage, and window) to native equivalents, and methods for verifying the migrated app.
Installation
npx skills add https://github.com/expo/skills --skill expo-web-to-native
Fichiers du skill
SKILL.md
Dernière synchronisation · 23 oct. 2026
agents/openai.yaml›
interface:
display_name: "Expo Web to Native"
short_description: "Migrate an existing web React app (Next.js, Vite, CRA) to a native iOS/Android app with Expo, screen by screen"
default_prompt: "Use $expo-web-to-native to migrate a web React codebase to native: assess and bucket screens, ship a DOM-component shell on day one, then nativize screens in priority order with @expo/ui and Expo Router."
references/false-friends.md›
# False Friends: web idioms → native
Disclosed reference for [`expo-web-to-native`](../SKILL.md). A *false friend* is a web idiom that looks portable but isn't - it compiles in your head, then breaks (or silently no-ops) on native. Each row is `web idiom → native equivalent → the gotcha`. Reach for this in steps 3–5 of a migration, and whenever you're learning native after the web.
This is the single source of truth for the mapping; the SKILL.md steps name a few high-traffic swaps but defer the full list here.
## Elements & text
| Web | Native | Gotcha |
|---|---|---|
| `<div>`, `<section>`, `<span>` | `<View>` | `View` is the only generic container; no semantic element variants. |
| `<p>`, `<h1>`, raw text in JSX | `<Text>` | **All** text must sit inside `<Text>`. Bare strings in a `View` throw. |
| `<img src>` | `<Image>` / `expo-image` | `expo-image` adds caching, blurhash, transitions. Remote images need an explicit `width`/`height` or a flex parent. |
| `<button>`, `<a>` | `<Pressable>` | No default styling, no hover. Wrap label text in `<Text>`. |
| `<input>` | `<TextInput>` | Controlled via `value` + `onChangeText` (not `onChange`/`e.target.value`). |
| `<ul>/<li>` rendered with `.map()` | `<FlatList>` / `<FlashList>` | `.map()` over a long list mounts every row at once → jank. Use a virtualized list with `data` + `renderItem`. |
## Events & interaction
| Web | Native | Gotcha |
|---|---|---|
| `onClick` | `onPress` | Also `onLongPress`, `onPressIn/Out`. There is no click. |
| `onChange={e => e.target.value}` | `onChangeText={text => ...}` | The handler receives the string directly, not an event. |
| `onSubmit` (form) | `onPress` / `onSubmitEditing` | No `<form>` element and no submit event; wire the button/keyboard return key. |
| `:hover`, `:focus` | press/focus state | No hover on touch. Track state and style from it, or use `Pressable`'s state callback. |
| `e.preventDefault()` | — | No default actions to prevent; remove it. |
## Styling & layout
| Web | Native | Gotcha |
|---|---|---|
| `className` / CSS files | `StyleSheet.create` or NativeWind | Plain RN has no className. For Tailwind muscle memory use NativeWind. |
| CSS cascade & inheritance | none | Styles don't inherit (except a few `Text` props like `color`/`fontSize` to nested `Text`). Style each node. |
| `display: flex` opt-in, default `row` | flex always on, default **`column`** | Biggest layout surprise: flex is the only layout model and the main axis is vertical by default. |
| `px`, `rem`, `%` | unitless numbers (dp) | `padding: 16` = density-independent pixels. `%` works in some props; no `rem`/`em`/`vh`. |
| `@media` queries | `useWindowDimensions()` / `Platform` | No media queries. Branch on dimensions or platform at runtime. |
| `position: fixed` | `position: absolute` + safe area | No `fixed`. Pin with `absolute` and respect insets (`react-native-safe-area-context`). |
| `z-index` | `zIndex` (+ `elevation` on Android) | Works mostly, but Android shadow/stacking also needs `elevation`. |
| CSS transitions / `@keyframes` | `Animated` / Reanimated | No CSS animation. Reanimated is the standard; gestures via `react-native-gesture-handler`. |
| `gap`, flexbox | `gap`, flexbox | These largely transfer — flexbox is the one big win for web devs. |
## Navigation & URLs
| Web | Native | Gotcha |
|---|---|---|
| React Router / Next.js routes | Expo Router (file-based) | File-based like Next App Router; `react-router` concepts map but the API differs. |
| `<a href>` / `<Link>` | `expo-router` `<Link href>` | Works in DOM components too. |
| `useNavigate()` / `router.push` | `useRouter()` from `expo-router` | `router.push/replace/back`. |
| `useParams` / `useSearchParams` | `useLocalSearchParams()` | Note: doesn't work *inside* a DOM component - read in the native parent, pass as props (see `expo-dom`). |
| `window.location` | `usePathname()` / router | No `location` object. |
| Deep links / `myapp://` | URL scheme + universal links | Configure `scheme` in app config; native gives you real deep linking and (with AASA) app links - see `expo-app-clip` for the AASA file. |
## React architecture & data (Next.js / SSR apps)
| Web | Native | Gotcha |
|---|---|---|
| Async Server Component (`async function Page()`) | client component + data fetch | No RSC on native (Expo's is experimental and different). Split into a client component that fetches via the API + a presentational child *before* porting. |
| Server actions (`'use server'`) | API route + `fetch` | No server actions. Expose an endpoint and call it. |
| `cookies()` / `headers()` (server) | token in `expo-secure-store` | Server-only. Auth moves to a bearer token sent as a header. |
| ORM / DB client in a page (Drizzle, Prisma) | server only - call over HTTP | The device never talks to the DB. Keep the backend deployed, or move routes to EAS Hosting (`eas-hosting`); the app fetches. |
| `next/navigation` (`notFound`, `redirect`) | `expo-router` | `redirect()` → `router.replace`; `notFound()` → a native `+not-found` route. |
| `_layout` as a DOM component | always native | Layout routes can't be DOM components — they *are* the native shell. Rewrite web-only layout logic (theme scripts, providers) with native APIs. |
## Storage, state & sessions
| Web | Native | Gotcha |
|---|---|---|
| `localStorage` / `sessionStorage` | `expo-sqlite` (`globalThis.localStorage`) | `import 'expo-sqlite/localStorage/install';` polyfills the global `localStorage`, so existing code works as-is (or `expo-sqlite/kv-store`). **No `sessionStorage` equivalent** — native has no session concept. |
| Cookies / `document.cookie` | `expo-secure-store` + headers | No cookie jar by default. Store tokens in SecureStore; send as auth headers. |
| Auth via session cookie | token in SecureStore | Redesign auth around bearer tokens, not browser sessions. |
| In-memory React state | same | `useState`/`useReducer`/`useContext` transfer unchanged. |
## Browser & platform APIs
| Web | Native | Gotcha |
|---|---|---|
| `window`, `document` | none | Don't exist. Guard web-only libs, or keep them in a DOM component (`expo-dom`). |
| `window.matchMedia('(prefers-color-scheme: dark)')` | `useColorScheme()` | Hook from `react-native`, reactive to the system theme. Web theme bootstrap scripts in the layout don't run. |
| `window.innerWidth/Height` | `useWindowDimensions()` | Reactive hook; updates on rotation. |
| `navigator.*`, Web APIs | Expo SDK modules | e.g. camera → `expo-camera`, geolocation → `expo-location`, clipboard → `expo-clipboard`. |
| `alert()` / `confirm()` / `prompt()` | `alert()` works; `Alert.alert(...)` for the rest | RN provides a global `alert()`; `confirm()` / `prompt()` don't exist — use `Alert.alert` with a button array. |
| `process.env.X` | `process.env.EXPO_PUBLIC_X` | Only `EXPO_PUBLIC_`-prefixed vars are inlined into the client bundle. |
| `console.log` | `console.log` | Works — shows in the dev tools/terminal. |
| `dangerouslySetInnerHTML` | — | No DOM to inject into. Render data, or use a DOM component / WebView. |
| inline SVG | `react-native-svg` | No native `<svg>`; the library mirrors the element API. |
## Networking
| Web | Native | Gotcha |
|---|---|---|
| `fetch('/api/x')` (relative) | absolute URL | Native has no origin — relative paths fail. Use a configured base URL (`EXPO_PUBLIC_API_URL`). |
| CORS | n/a | No browser CORS, but you still need a reachable absolute host. |
| `fetch`, React Query, SWR | same | The libraries themselves work on native - see `expo-data-fetching`. |
| Next.js API routes | Expo Router API routes | Move server endpoints to Expo API routes on EAS Hosting - see `eas-hosting`. |
| Streaming responses (SSE, AI SDK `useChat`) | `expo/fetch` with `textStreaming` | RN's built-in `fetch` can't read a streaming response body. Use `expo/fetch` (streams, and works with the Vercel AI SDK) or an XHR-based polyfill. |
## Third-party services & SDKs
Browser SDKs don't run on native — each needs a native equivalent. The canonical, version-matched integrations live in the `expo-examples` skill (the `expo/examples` repo); reach for those instead of wiring from scratch.
| Web service | Native | Gotcha |
|---|---|---|
| **Stripe.js — digital goods / subscriptions** | store **In-App Purchase** via **RevenueCat** (`react-native-purchases`) | **Policy, not just an SDK swap.** Apple & Google *require* IAP for in-app digital goods and take ~15–30%; shipping Stripe for them gets the app rejected. Decide at assess time — it can change the business model. |
| **Stripe.js — physical goods / services** | `@stripe/stripe-react-native` (PaymentSheet, Apple/Google Pay) | Stripe's RN SDK is allowed for physical goods; see `expo-examples` `with-stripe`. |
| Google Maps JS | `react-native-maps` | A native map view, not an embedded iframe. |
| Web Push | `expo-notifications` (APNs / FCM) | Different delivery + permission model. |
| OAuth redirect flow | `expo-auth-session` (deep-link OAuth), or a native auth SDK (Clerk, Supabase…) | No browser redirect — an app deep link completes the flow. |
| GA / Segment.js analytics | native SDK (`posthog-react-native`, Segment RN) | The web JS snippet won't load; swap for the native SDK. |
| `<input type=file>` / `getUserMedia` | `expo-image-picker` / `expo-camera` / `expo-document-picker` | Native pickers + runtime permissions. |
## Scrolling, keyboard & safe area
| Web | Native | Gotcha |
|---|---|---|
| Body scrolls by default | `<ScrollView>` / `<FlatList>` | Nothing scrolls unless you make it. Wrap overflowing content explicitly. |
| `overflow: scroll` | `<ScrollView>` | Don't nest a `ScrollView` inside a `FlatList` of the same axis. |
| Keyboard overlaps inputs | `KeyboardAvoidingView` | The keyboard covers inputs by default; wrap forms to push content up. |
| Notch / status bar | `react-native-safe-area-context` | Content flows under the notch/home indicator unless you apply safe-area insets. |
| `:focus` ring, tab order | manual | No automatic focus management; wire `ref`/`focus()` and `returnKeyType`. |
references/native-patterns.md›
# Native patterns: redesigning web UX for native
Disclosed reference for [`expo-web-to-native`](../SKILL.md), step 4. `false-friends.md` translates *idioms* (`div` → `View`); this translates *UX patterns* - a web interaction into its native equivalent. Step 4 isn't a port, it's this redesign.
**Reach for `@expo/ui` first.** It renders real SwiftUI (iOS) and Jetpack Compose (Android), so its components look and feel *exactly* like the OS — the difference between "native-ish RN" and "indistinguishable from an app Apple/Google shipped." See the `expo-ui` skill. Drop to styled RN primitives only for what `@expo/ui` doesn't cover: custom layouts (chat bubbles, bespoke cards) and **large data lists** (`@expo/ui` `List` is a JS-thread node per item — use `FlashList`/`FlatList` for feeds). `@expo/ui` runs in **Expo Go** (SDK 56+) — no dev build needed; reach for a dev build (the `expo-dev-client` skill) only for *custom* native modules.
| Web pattern | Native redesign — reach for first | Why |
|---|---|---|
| Top tab bar / nav links | **NativeTabs** - bottom, liquid glass on iOS 26 (`expo-router`) | Thumb-reachable, OS-native bar |
| Page header / breadcrumb | **large-title** Stack header + header **search field** (`expo-router`) | The native screen frame |
| In-page tabs / toggle group | **SegmentedControl** — `@expo/ui` (`community/segmented-control`) | Native segmented switch |
| Modal / dialog | **BottomSheet** — `@expo/ui` | Sheets *are* the native modal |
| `<select>` / dropdown | **Picker**, or long-press **MenuView** — `@expo/ui` (`community/menu`) | Native wheel / menu, no popover |
| Accordion / "show more" | **Collapsible** — `@expo/ui` | Native disclosure |
| Settings / short list | **List** + **FieldGroup** rows (Switch / Picker / TextInput) — `@expo/ui` | The Settings-app look |
| Data feed / table / long list | **FlashList** / `FlatList`, virtualized + momentum | Tables don't exist on mobile; `@expo/ui` `List` isn't for big data |
| Checkbox / toggle | **Switch** (on/off) or **Checkbox** (multi-select) — `@expo/ui` | Native control, not a styled box |
| Date / time input | **DateTimePicker** — `@expo/ui` (`community/datetime-picker`) | Native wheel / calendar |
| Range slider | **Slider** — `@expo/ui` | |
| Onboarding / carousel / swipe pages | **PagerView** — `@expo/ui` (`community/pager-view`) | Native paging, not a scroll strip |
| Refresh button / auto-poll | pull-to-refresh (`RefreshControl`) | The native refresh gesture |
| Hover menu / tooltip | long-press **MenuView** context menu — `@expo/ui` (`community/menu`) | No hover on touch |
| Toast / snackbar | native banner + **haptic** (`expo-haptics`) | |
| Buttons | `@expo/ui` **Button**; `Pressable` for custom | Native press + haptics |
| Multi-column layout | single column + tabs / stack | One thing at a time on a phone |
**The test:** if a screenshot of the native screen could pass for the web version, you reskinned it. Redesign until it could pass for a screen the OS shipped.
## Feel — beyond the components
The table gets the right *components*; "native" also lives in *motion and touch* - and a web port arrives with almost none, so you must add it. The tooling is in `expo-router` (navigation/transitions) and `expo-native-ui` (motion/effects); reach for it, don't hand-roll:
- **Transitions for free** - use Expo Router's native-stack so push/pop, modals, and sheets animate with real platform physics; shared-element zoom via `expo-router` `zoom-transitions.md`.
- **Motion** - Reanimated (`withSpring`/`withTiming`) + `react-native-gesture-handler` for swipes/drags → load the `expo-animation` skill (purpose gate, timing values, interruption).
- **Touch** — `expo-haptics` on commits, selection changes, and pull-to-refresh — choreography, not just a toast buzz.
- **Native rhythm** — large-title collapse on scroll, momentum / inverted scroll, a keyboard that pushes content (`KeyboardAvoidingView`).
- **Respect reduced motion** — gate non-essential animation on Reanimated's `useReducedMotion()`.
**Feel can't be screenshotted.** A janky transition or wrong easing passes a still-image parity check and still betrays the app — for screens with motion, verify with a short recording (iOS `xcrun simctl io booted recordVideo feel.mov`; Android `adb shell screenrecord`; or a device-agent flow), not just a screenshot.
references/run-as-goal.md›
# Run the migration as a goal loop
Disclosed reference for [`expo-web-to-native`](../SKILL.md) - the recommended way to drive the whole migration. The migration is a repeat-until-done loop (assess → nativize each screen → verify → check off), which is exactly the shape of a **goal loop**: a single objective re-injected every turn until the worklist is empty. This file carries a ready-shaped, migration-specific objective - and, crucially, the objective **re-reads this skill every iteration**, so the loop keeps following the playbook (and self-bootstraps the assess step if no worklist exists yet).
Use it in one of two modes depending on the agent you're running.
## Mode A — the agent can run a goal loop
Harnesses with a goal/loop command (Claude Code `/goal`, Codex CLI, or any harness of the same shape) can drive this themselves. Fill the template below and hand it to that command — the loop re-runs it until `migration-progress.md` has no unchecked nativize-now items.
## Mode B — the agent can't loop itself
Write the filled-in objective to `migration-goal.md` in the project, then give the user the one-line instruction to launch it (e.g. *"run your agent's goal loop with the objective in `migration-goal.md`"*). The objective is plain text and portable, so the user runs it in whatever harness they have.
## The objective (template)
Fill the two `<…>` slots, then run or hand off verbatim. It is written to survive re-injection — it restates its own worklist, direction, and stop condition every turn.
```
Goal: migrate <APP NAME> from web to a native Expo app by following the expo-web-to-native skill, one screen per iteration, until done.
Each iteration, FIRST re-read the playbook - plugins/expo/skills/expo-web-to-native/SKILL.md (and its references) - then:
1. If migration-progress.md doesn't exist yet, do the skill's step 1 (Assess) to
create the worklist, then stop. Otherwise open it and take the top unchecked
item under "nativize-now"; if none are left unresolved (every nativize-now is
done or blocked), STOP and summarize what shipped + what's blocked and why.
2. Redesign that screen native per the skill's step 4 — reach for @expo/ui FIRST
(real SwiftUI/Compose), then expo-router (NativeTabs, large titles);
RN primitives only for custom layouts. NEVER a webview port.
Use references/native-patterns.md (UX patterns) and references/false-friends.md
(idioms). Match the web screen's content and behavior.
3. Verify per references/verify-on-device.md: compare the running web original
(browser agent) against the native screen (simulator / argent) — content and
behavior parity, NOT pixels (it should look more native). If it's off for reasons
IN the code, fix it this iteration. If it's blocked by something OUTSIDE the code
(missing backend/auth/data, or a secret you can't supply), don't loop — mark it
blocked in step 4 and move on.
4. Check the item off in migration-progress.md, appending one line:
"<screen> — done", or "<screen> — blocked: <reason> — needs <what would unlock>".
Either way the item is resolved; never revisit a blocked one.
Rules: one screen per pass; the app builds green each iteration; @expo/ui before RN primitives; never touch "nativize-later" items.
Base API URL for native (no relative paths): <EXPO_PUBLIC_API_URL>
```
## Why a pre-shaped objective
The objective is filled in for this migration — worklist, redesign rules, and self-verification path already wired to the skill's references — so you can launch the loop without authoring one from scratch. Tailor the `<…>` slots and direction when a specific migration needs more than the template covers.
references/verify-on-device.md›
# Verify a migrated screen against the running web app
Disclosed reference for [`expo-web-to-native`](../SKILL.md), steps 3–4. Verification means **running both apps and comparing the same screen** - the native port beside the web original. A clean compile or a green `expo export` proves nothing - a screen can build and still render blank or mis-render. This parity check is the gate the strangle loop runs each iteration.
Two agents drive it, and this skill is **opinionated**: it requires both and installs them rather than falling back to manual screenshots.
- **Web side — `agent-browser`** (vercel-labs, a Rust browser CLI): `open <url>`, `snapshot --json` (accessibility tree with refs), `screenshot <path>`, `read` (rendered DOM/text).
- **Native side — `argent`** (`@swmansion/argent`): drives the simulator — `describe` (a11y tree), `debugger-component-tree` (RN tree), `gesture-tap`/`keyboard`, `flow` to record a check and replay it each pass. Invoke as `argent run <tool> --udid <udid>` (get the udid from `argent run list-devices`).
## Setup — install if missing (don't skip, don't fall back to manual)
Check both are on PATH; if either is absent, ask the user, then install before proceeding:
```bash
which agent-browser || (npm i -g agent-browser && agent-browser install) # web agent (+ its Chrome)
which argent || npm i -g @swmansion/argent # device agent
```
## The workflow
**A. Capture the web original** with agent-browser — the source of truth for parity. Run the web app (its `pnpm dev` server, or the **deployed URL** if local setup needs DB/auth env), then:
```bash
agent-browser open "<web-url>/<route>?<params>"
agent-browser snapshot --json # structure to diff
agent-browser screenshot web.png # visual reference
```
**Tip:** capture web baselines for every screen once, up front, then diff against them instead of re-opening the web app each iteration.
**B. Capture the native screen** (iOS shown via `simctl`; Android note below):
1. Run the app: `npx expo start --ios` (Expo Go). To keep the onboarding sheet, dev menu, and Tools button out of your screenshots on SDK 57 and earlier, and on the URLs you open yourself in step 3, see `../../expo-overview/references/launch-without-dev-menu.md`. On **SDK 56+ both `@expo/ui` and DOM components run in Expo Go** — no dev build, no `react-native-webview` to install; reach for a dev build (the `expo-dev-client` skill) only for *custom* native modules. Stale-bundle trap: a CI-mode Metro + cached Expo Go can show an old build — terminate Expo Go and add `--clear` if a change doesn't appear.
2. Boot a sim: `xcrun simctl boot <udid>` (`xcrun simctl list devices available`); `open -a Simulator`.
3. Open the route: deep-link `xcrun simctl openurl booted "exp://<lan-ip>:8081/--/<route>?<params>"`, or argent `launch-app` + `gesture-tap`.
4. Capture: `xcrun simctl io booted screenshot native.png`, or `argent run describe --udid <udid>` for structure.
> **Android:** `simctl` / `expo run:ios` are iOS-only. Use an Android emulator + `adb` — `adb exec-out screencap -p > native.png`, `adb shell am start -a android.intent.action.VIEW -d "<deep-link>"`, `adb shell screenrecord` for motion — or `npx expo run:android` for a dev build.
**C. Compare** the two for the same route — layout, content, behavior. Diff the structures (agent-browser `snapshot` against argent `describe` / `debugger-component-tree`), not just pixels. Pass only on parity: same data, and params passed into a DOM webview must produce the same result.
**Feel needs motion, not a still.** For a nativized screen with transitions, gestures, or haptics, a screenshot can't catch a janky push or wrong easing — capture a short recording (iOS `xcrun simctl io booted recordVideo feel.mov`; Android `adb shell screenrecord`; or an argent flow) and confirm it moves like a native app (see `native-patterns.md` → Feel).
## What "pass" looks like
- **DOM-shelled screen (step 3):** the web UI renders inside the native header/shell; params from the native route drive it the same as on web.
- **Nativized screen (step 4):** native primitives only, no webview; matches the web original screen.
SKILL.md›
---
name: expo-web-to-native
description: Migrate an existing web React app to a native iOS/Android app with Expo. Use when the user wants to turn a website into a mobile app, port a Next.js/Vite/CRA React codebase to React Native, reuse web code on native incrementally, or asks how web idioms (the DOM, CSS, React Router, localStorage, window) map to native. This is the end-to-end migration guide; use the `expo-dom` skill for the DOM-component mechanism itself.
version: 1.0.0
license: MIT
---
# Web to Native
A web React app does not *convert* to native — there is no transpiler. It **migrates**, screen by screen, the way a strangler fig grows around a tree and slowly replaces it: stand up a native shell, run the whole web UI inside it on day one, then strangle each screen into native in priority order. This skill is the spine that orders the work; each step hands off to an existing Expo skill rather than re-explaining it. It operationalizes Expo's [From Web to Native with React](https://expo.dev/blog/from-web-to-native-with-react) — read that for the why.
```mermaid
flowchart TD
A1[1 · Assess: write the worklist] --> A2[2 · Scaffold Expo shell]
A2 --> A3[3 · DOM-component shell<br/>· expo-dom · SHIP DAY ONE]
A3 --> A4[4 · Strangle screens to native<br/>highest-value first · expo-router]
A4 -->|more screens| A4
A4 --> A5[5 · Wire data / auth / storage<br/>· expo-data-fetching]
A5 --> A6[6 · Ship · eas-app-stores]
```
## Principles
- **Migrate, don't rewrite.** Never big-bang it; every step keeps the app shippable.
- **Ship on day one.** The web UI runs in a DOM-component shell (step 3) before anything is nativized — that's the milestone; everything after is polish.
- **Strangle by value.** Nativize the hot screens; leave the rest in the webview. Each DOM screen carries a ~2 MB web runtime — reason enough not to ship everything as DOM.
- **Nativize means redesign, not reskin.** A strangled screen should look like Apple/Google shipped it, not the web page reskinned. **Reach for `@expo/ui` first** - it renders real SwiftUI/Compose, so it feels *exactly* like the OS; styled RN primitives are the fallback for custom layouts only. Plus platform navigation (`expo-router`: NativeTabs, large titles), liquid glass and native components via `@expo/ui`, and mobile UX (sheets, swipe, haptics). The web→native pattern map is [`./references/native-patterns.md`](./references/native-patterns.md). If it still feels like a website, you ported instead of redesigned.
- **Verify by running, not compiling.** A clean build proves nothing (a blank webview compiles fine). Run each screen — but judge *content and behavior* against the web original, not pixels (a nativized screen should look more native, not identical).
- **Orchestrate, don't reinvent.** Each step routes into an existing skill. The value here is the *order* and the *gotchas* — the idiom-by-idiom mappings live in [`./references/false-friends.md`](./references/false-friends.md).
## Run it as a loop (recommended)
The migration is a long repeat-until-done loop, so the first move is to **write the goal objective and launch it** — not to grind screens by hand. Fill the objective in [`./references/run-as-goal.md`](./references/run-as-goal.md) for this app and present it; it **re-reads this skill every iteration**, so each `/goal` turn reloads the playbook + worklist and drives the next screen (it even self-bootstraps the assess step). Then run `/goal` with it — or, if the harness can't loop, write it to `migration-goal.md` and have the user launch it. The steps below are what each iteration does; run them by hand only if you're not looping.
## The migration
> **No repo to migrate** - just building native fresh as a web dev? You don't need these steps: use `expo-router`, and keep [`./references/false-friends.md`](./references/false-friends.md) open for the web→native idiom map. Everything below assumes an existing web app.
### 1. Assess → write the worklist
Read the repo and produce `migration-progress.md`, the durable worklist the rest of the migration checks off. Make two cuts:
- **Screens vs backend.** Page routes (`page.tsx`) are screens you migrate; server routes (`route.ts`), the ORM, and auth handlers stay server-side. Decide the backend once: keep it deployed (the native app becomes an HTTP client) or move it to EAS Hosting (`eas-hosting`).
- **Bucket each screen** by how it should land: **port-as-is** (presentational → ships in a DOM webview), **nativize-now** (hot, or needs native feel — gestures, lists, keyboard), **nativize-later**, or **hybrid** (a native shell around a web sub-tree, e.g. a chat list wrapping a markdown renderer).
Note the framework signals as you read — RSC vs client, Tailwind/shadcn, where data is fetched — since they decide how each screen ports (false-friends has the mappings; async Server Components in particular must be split into a client fetch + a presentational component before they can move). **Flag third-party services/SDKs too** — browser SDKs don't carry over (`false-friends` → *Services & SDKs*); payments especially is a *fork, not a swap* (in-app digital goods must use store IAP via RevenueCat, ~30% — not Stripe), a business-model call to make now, not at App Store review. The worklist is only trustworthy once every route is sorted and every screen bucketed.
### 2. Scaffold the shell
`create-expo-app`, then mirror the web routes in Expo Router — Next's tree maps almost 1:1 (note `[id]/page.tsx` → `[id].tsx`, and routes may live in `src/app/`). Empty screens, one per route.
### 3. Shell it in DOM components — the day-one milestone
Bring every screen over as a DOM component (`'use dom'`, per the `expo-dom` skill) rendered by its native route, so the whole app runs on a phone before anything is nativized. Expect per-screen edits - unwrapping Server Components, swapping framework imports (`next/link`), carrying the styling over - all covered in false-friends. Then verify by running (below); this is shippable to TestFlight as-is.
### 4. Strangle screens to native — by value
Walk `migration-progress.md` top-down. For each screen, *redesign* it native - don't port the web layout. Reach for **`@expo/ui` first** (real SwiftUI/Compose - buttons, lists, sheets, pickers, sliders; [`./references/native-patterns.md`](./references/native-patterns.md) maps which web pattern becomes which native component), then platform navigation (`expo-router` - NativeTabs, large titles) and mobile UX (swipe, haptics, momentum/inverted scroll); RN primitives only for custom layouts. Consult [`./references/false-friends.md`](./references/false-friends.md) for each idiom. `@expo/ui` and DOM components both run in **Expo Go** (SDK 56+) - a dev build (the `expo-dev-client` skill) is only needed for *custom* native modules. Verify *content and behavior* against the running web original (the look should become more native), then check it off. One screen per pass, app shippable throughout. It's a loop over a durable worklist, so it can run unattended - hand it to a goal loop ([`./references/run-as-goal.md`](./references/run-as-goal.md)).
### 5. Wire data, auth, and storage
The web data layer doesn't survive the move - relative fetches, cookie sessions, `localStorage`, and env vars all change (swaps in false-friends). Use `expo-data-fetching` for requests and caching; add `eas-hosting` if the backend moved to EAS Hosting.
### 6. Ship
`eas-app-stores` for the store builds (App Store / Play / TestFlight), EAS Update for OTA pushes after.
## Verify by running, not compiling
A green `expo export` proves a screen *bundles*, not that it *renders* — a screen can build and still render blank or mis-render. So after the shell and after every nativized screen, compare the two **running** apps for the same route:
- **Web original** — capture it with **`agent-browser`** (vercel-labs CLI): `open` the route, `snapshot --json` the accessibility tree, `screenshot`.
- **Native** — drive the simulator with **`argent`**: `describe` / `debugger-component-tree` for structure, `flow` to replay the check each pass.
Pass on parity of **content and behavior** — not pixels: a nativized screen should look *more* native than the web, never identical (the DOM-shell stage is the exception — there it *is* the web UI, so it should match). Feel is part of native and can't be screenshotted — for screens with transitions or gestures, capture a short recording, not just a still (see `native-patterns.md` → Feel). This loop is **opinionated about its tooling**: if `agent-browser` or `argent` isn't installed, ask the user and install it before proceeding — don't fall back to manual screenshots. Full recipe and setup in [`./references/verify-on-device.md`](./references/verify-on-device.md).
## References
- [`./references/false-friends.md`](./references/false-friends.md) — web idiom → native equivalent + the gotcha for each. The lookup for steps 3–5, and for any web dev unlearning idioms.
- [`./references/native-patterns.md`](./references/native-patterns.md) — web UX *pattern* → native redesign (`@expo/ui`-first). The step-4 redesign playbook so screens feel OS-native, not reskinned.
- [`./references/verify-on-device.md`](./references/verify-on-device.md) — the two-agent parity recipe: drive the web app (browser agent) and the native app (argent), open the same route, compare.
- [`./references/run-as-goal.md`](./references/run-as-goal.md) — a ready-shaped, migration-specific goal objective for driving step 4 unattended (re-reads this skill each iteration).
- [Expo — From Web to Native with React](https://expo.dev/blog/from-web-to-native-with-react) — the canonical guide this skill operationalizes.
## Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
```bash
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-web-to-native" "<actionable feedback>"
```
Only submit when you have something specific and actionable to report. Include as much relevant context as possible.
If an agent repeatedly fails at an Expo task or the user has to step in, submit a brief report of the task, expected result, and what went wrong using `--category evals` and the relevant Expo package or command as `--subject`.