AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a systematic, screen-by-screen migration strategy to transition React web codebases into native mobile applications. Rather than a full rewrite, it advocates for a strangler fig approach: scaffolding a native shell, running the web UI via DOM components, and incrementally replacing screens with native components. It emphasizes redesigning for mobile UX using native patterns and components, rather than simply porting web layouts, and provides a loop-based process for tracking progress and verifying parity between the web and native versions.
What it does
- Provides a step-by-step migration roadmap from web to native.
- Maps web idioms like DOM, CSS, and React Router to native equivalents.
- Guides the incremental replacement of web screens with native UI components.
- Integrates with Expo tooling for data fetching, navigation, and store deployment.
- Defines a verification process to compare web and native screen behavior.
- Offers a goal-oriented loop for managing the migration worklist.
When to use it
- When you need to turn an existing React web application into a native mobile app.
- When porting a Next.js, Vite, or Create React App codebase to React Native.
- When you want to reuse web code incrementally while transitioning to native.
- When you need guidance on mapping web-specific concepts to native mobile patterns.
Advantages
- Allows for an incremental, shippable migration process.
- Enables immediate deployment of a web-based shell on mobile devices.
- Focuses on native UX redesign rather than simple reskinning.
- Provides clear mappings for web-to-native idioms and patterns.
- Supports a structured, repeatable loop for tracking migration progress.
Usage examples
- “I have a React web app and want to migrate it to native using Expo. How do I start?”
- The agent will initiate the assessment phase, helping you create a migration worklist and scaffold the initial native shell.
- “How do I map my web CSS and layout to native components?”
- The agent will reference the false-friends and native-patterns guides to suggest appropriate native components like View, Text, or @expo/ui elements.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/expo/expo-web-to-native/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip expo-web-to-native.zip -d .agents/skills
Documentation by Expo
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 — read that for the why.
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/uifirst - 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. 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.
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 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.mdopen 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 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 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).
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):openthe route,snapshot --jsonthe accessibility tree,screenshot.Native — drive the simulator with
argent:describe/debugger-component-treefor structure,flowto 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
./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— 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— 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— 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 — 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:
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.
Files
- ATTRIBUTION.mdAdded by AutoGineer823 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SKILL.md10.2 KiB
- agents/openai.yaml406 B
- references/false-friends.md9.9 KiB
- references/native-patterns.md4.3 KiB
- references/run-as-goal.md3.6 KiB
- references/verify-on-device.md4.1 KiB
references/false-friends.md
False Friends: web idioms → native
Disclosed reference for expo-web-to-native. 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. |
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Instructions that use the network
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Expo
- Publisher
- Expo (Official)
- Source
- expo/skills · plugins/expo/skills/expo-web-to-native · commit a9e5bd3
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from plugins/expo/LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Expo.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download expo-web-to-native.zip