| name | scaffold-cross-platform-app |
| description | Scaffolds React Native (Expo Router) and Flutter app shells — feature-first folder layout, typed navigation + deep links, client-store wiring (Zustand/Redux Toolkit/Riverpod/Bloc), platform-divergent code and native bridges (Expo config plugin/Flutter platform channel), token-driven theming with dark mode, and env/build-flavor tooling. |
| when_to_use | Standing up or restructuring a whole React Native (Expo) or Flutter app — choosing navigation, client state, platform-conditional code, bridging a native module, theming, and build flavors. Distinct from build-native-mobile-ui (SwiftUI/Compose screens, not RN/Flutter), manage-client-server-state (server cache/data fetching), design-token-system (the token pipeline this skill consumes), and ship-mobile-app-store-release (signing + store upload). |
When to Use
Reach for this skill when the request is about standing up or reorganizing a whole RN/Flutter app, not a single screen:
- "Set up a new Expo app with tabs + a typed navigation stack and deep links"
- "Start a Flutter app with go_router and Riverpod, organized by feature"
- "Pick state management — Redux Toolkit vs Zustand / Bloc vs Riverpod — and wire it"
- "I need iOS-only and Android-only versions of this code / an adaptive widget"
- "Bridge a native module / write an Expo config plugin / add a Flutter platform channel"
- "Apply our design tokens + dark mode across the app shell"
- "Add dev/staging/prod flavors with separate env, bundle IDs, and icons"
NOT this skill:
- A native iOS/Android screen in SwiftUI or Jetpack Compose (not RN/Flutter) → build-native-mobile-ui
- Building one reusable RN component in an existing tree → build-react-component
- Server-cache, fetching, optimistic updates, query invalidation → manage-client-server-state (this skill wires client state only)
- Designing the token architecture/pipeline (primitive/semantic tiers, Style Dictionary, W3C export) → design-token-system (this skill consumes the exported tokens)
- Pixel-matching a Figma/screenshot for a screen → implement-from-design
- Tailwind/responsive web layout → style-responsive-tailwind
- E2E flows on the running app → write-playwright-e2e
- Code signing, keystores, TestFlight/Play upload, phased rollout → ship-mobile-app-store-release
- The CI workflow that calls build/sign/upload lanes (EAS/Codemagic/Fastlane in CI) → cicd-pipeline-author
- Storing signing keys / API secrets safely → secrets-management
Steps
-
Pick the framework lane and don't drift mid-project. Default to Expo (managed) + Expo Router for RN, Flutter stable + go_router for Dart. Go bare RN only when a dependency needs native build config the managed prebuild can't express.
| Need | RN choice | Flutter choice |
|---|
| Standard app, OTA updates, fast start | Expo managed + expo-dev-client | Flutter stable |
| Custom native code you control | Expo + config plugin (stay managed) | Flutter + plugin/FFI |
| Native build settings Expo can't model | bare RN (expo prebuild then own ios/,android/) | n/a |
| Routing | Expo Router (file-based, typed) | go_router (typed routes) |
| New project command | npx create-expo-app@latest -t default | flutter create --org com.acme app |
Reject React-Navigation-only (no router) for new apps: Expo Router is React Navigation underneath but gives file-based deep linking for free.
-
Lay out feature-first, not type-first. Group by domain so a feature is one deletable folder. Avoid the top-level screens/ components/ reducers/ split — it scatters every feature across the tree.
src/
app/ # Expo Router routes (file = route). Flutter: lib/routing/
(tabs)/index.tsx # deep link: myapp:// → /
(tabs)/profile.tsx
post/[id].tsx # myapp://post/42
_layout.tsx # Stack/Tabs + theme provider
features/
auth/ { ui/ store.ts api.ts types.ts }
feed/ { ui/ store.ts api.ts }
shared/ { ui/ hooks/ theme/ lib/ }
platform/ # *.ios.tsx / *.android.tsx live next to use site
Flutter mirror: lib/features/<x>/{presentation,application,data,domain}, lib/core/theme, lib/routing/app_router.dart.
-
Make routes typed and deep-linkable from day one.
- Expo Router: enable typed routes in
app.json → "experiments": { "typedRoutes": true }. Set in () so resolves; for universal/app links add or . Nest with : a group holds , a sibling holds a for modals/detail. Navigate with — params are type-checked.
Common Errors
- Type-first folders (
screens/, reducers/, components/). Every feature smears across the tree; deleting a feature touches 6 folders. Group by feature, share only truly shared code in shared/.
- One global store for everything including server data. Caching API responses in Zustand/Redux means manual invalidation and stale UI. Put server cache in TanStack Query / Riverpod
AsyncNotifier; keep the store for session/UI state.
Platform.OS checks buried in business logic. Divergence leaks everywhere and is untestable. Isolate it at the UI/platform layer via .ios/.android files or Platform.select.
- Editing
ios/ or android/ by hand on a managed Expo app. The next prebuild wipes it. Express native changes as a config plugin or Expo Module instead.
- Native change with no rebuild. Hot reload/Fast Refresh only reloads JS/Dart. A new native module or channel needs
expo prebuild --clean / flutter clean + a fresh native build, or you'll debug a phantom "method not found."
- Hardcoded hex colors / magic spacing. Dark mode and rebrands become a find-and-replace. Pull every color/space/radius from the token theme; derive light+dark from one source.
- Missing
scheme / intent-filter, so deep links silently no-op. Set scheme in app.json (RN) and the Android <intent-filter> + iOS CFBundleURLTypes (Flutter) to match the route table, or myapp://post/42 opens the app to the home screen.
- Mismatched platform-channel/method names across Dart↔native. A typo yields a silent
MissingPluginException at runtime. Keep channel + method strings in one shared constant referenced by both sides.
- Same
bundleIdentifier/applicationId across flavors. Dev and prod overwrite each other on-device and can't coexist. Give each flavor a distinct id + icon + display name.
- Untyped navigation params.
router.push('/post/' + id) loses type-checking and breaks on refactor. Enable typed routes (Expo) / named go_router routes and pass params as objects.
Verify
Run on both an iOS simulator and an Android emulator/device — a single-platform pass proves nothing cross-platform.
- Boots clean both OSes:
npx expo run:ios and npx expo run:android (or flutter run -d ios / -d android) start with no red box / no exception, app reaches the first screen.
- Typed navigation + deep links: a wrong route param fails
tsc --noEmit/flutter analyze. xcrun simctl openurl booted myapp://post/42 and adb shell am start -a android.intent.action.VIEW -d "myapp://post/42" both open the correct detail screen with the right id.
- State wiring: an action mutates the store and exactly the subscribed components re-render (verify with a render log/devtools); unrelated screens do not. Server data lives in the query cache, not the store.
- Platform divergence resolves: the
.ios/.android (or adaptive) variant renders the native-looking control on each OS — confirm by screenshot, not assumption.
- Native bridge round-trips: call the module/channel method on both platforms and get a real value back (not
-1/MissingPluginException); confirm a rebuild was done after the native edit.
- Theming + dark mode: toggle system appearance on each OS → colors/typography flip via tokens, no hardcoded color survives; no contrast regressions.
- Flavors: build
dev and prod → distinct bundle id + icon + name, each reading its own env, no committed secret in the bundle.
- Lint/types green:
tsc --noEmit + eslint . (or flutter analyze) pass with zero errors.
Done = the app builds and runs on iOS and Android, deep links and typed nav resolve on both, state/theming/native-bridge round-trip correctly per platform, and lint + typecheck are green.