React Portability Patterns
This stack targets Next.js (web) and Expo (iOS, Android, and often web) from one codebase. React Portability Patterns are how we share one API in feature code while still using each platform’s best primitives under the hood.
For a general tour of React portability patterns, there’s a video in Further watching at the bottom of this page (Must know React Portability Patterns — Jack Herrington). Everything below is this starterkit’s take:
CoreContext,UniversalAppProviders, and Expo vs Next wiring.
Why platform file splits are not always enough
Metro and TypeScript support suffixes like .web.ts, .ios.ts, and .android.ts. Those are great for native vs web differences.
They are not always enough when:
- “Web” means two different stacks — Next.js App Router and Expo Router both run on web but use different routing APIs and lifecycles.
- You want one stable import (
useRouter,Image, etc.) in shared features while injecting optimized implementations per app root.
So we combine suffix splits with explicit Next vs Expo modules and runtime injection at the root layout.
The pattern
- Shared contract —
*.types.tsdefines the hook/component surface both platforms must satisfy. - Per-environment implementations —
*.next.ts(x)and*.expo.ts(x)wrap Next.js or Expo Router /next/image/expo-image, etc. - Thin universal entry —
*.ts/*.tsxreads the implementation fromCoreContext(wired throughUniversalAppProvidersin each app’s root layout). - Root wiring — The Expo app passes the Expo implementation; the Next app passes the Next implementation. Feature code stays unchanged.
That way you reuse optimized versions per environment without refactoring every screen when you add or change a platform.
Where the wiring lives
Expo and Next each have an app root that imports *.expo / *.next implementations and passes them into UniversalAppProviders. That provider fills CoreContext so shared code in packages/@green-stack-core can use one API (useRouter, Image, …) everywhere.
- ExpoRootLayout.tsx ← passes Expo implementations
- NextClientRootLayout.tsx ← passes Next implementations
- UniversalAppProviders.tsx ← CoreContext.Provider
- CoreContext.tsx
- useRouter.ts
- useRouter.expo.ts
- useRouter.next.ts
- useRouter.types.ts
- Image.tsx
- Image.expo.tsx
- Image.next.tsx
CoreContext at app roots
UniversalAppProviders is the single place that puts the platform implementations on CoreContext. Feature code never imports *.expo or *.next directly—it uses the universal modules (useRouter, Image, …) which read from context.
import { CoreContext } from '@green-stack/context/CoreContext'
// ...inside UniversalAppProviders — props are unpacked from the app root
<CoreContext.Provider
value={{
contextImage,
contextLink,
contextRouter,
useContextRouteParams,
isExpo,
isNext,
isDebugMode,
setIsDebugMode,
requestContext: isServer ? props.requestContext : {},
}}
>
{children}
</CoreContext.Provider>Each app root imports the Expo or Next implementations and passes them into UniversalAppProviders:
import UniversalAppProviders from '@app/screens/UniversalAppProviders'
import { Image as ExpoContextImage } from '@green-stack/components/Image.expo'
import { Link as ExpoContextLink } from '@green-stack/navigation/Link.expo'
import { useRouter as useExpoContextRouter } from '@green-stack/navigation/useRouter.expo'
import { useRouteParams as useExpoRouteParams } from '@green-stack/navigation/useRouteParams.expo'
export default function ExpoRootLayout() {
const expoContextRouter = useExpoContextRouter()
return (
<UniversalAppProviders
contextImage={ExpoContextImage}
contextLink={ExpoContextLink}
contextRouter={expoContextRouter}
useContextRouteParams={useExpoRouteParams}
isExpo
>
{/* ... */}
</UniversalAppProviders>
)
}"use client"
import { isServer } from '@app/config'
import UniversalAppProviders from '@app/screens/UniversalAppProviders'
import { Image as NextContextImage } from '@green-stack/core/components/Image.next'
import { Link as NextContextLink } from '@green-stack/core/navigation/Link.next'
import { useRouter as useNextContextRouter } from '@green-stack/navigation/useRouter.next'
import { useRouteParams as useNextRouteParams } from '@green-stack/navigation/useRouteParams.next'
const NextClientRootLayout = ({ children, requestContext }) => {
const nextContextRouter = useNextContextRouter()
return (
<UniversalAppProviders
contextImage={NextContextImage}
contextLink={NextContextLink}
contextRouter={nextContextRouter}
useContextRouteParams={useNextRouteParams}
requestContext={isServer ? requestContext : {}}
isNext
>
{children}
</UniversalAppProviders>
)
}Note:
useRouterfrom the platform file returns the object stored ascontextRouter(Expo calls the hook once at the root; Next does the same). Universal hooks likeuseRouter()from@green-stack/navigation/useRouterthen readcontextRouterfromCoreContextanywhere under these providers.
Examples in this repo
Navigation hooks
- useRouter.expo.ts
- useRouter.next.ts
- useRouter.ts
- useRouter.types.ts
- useRouteParams.expo.ts
- useRouteParams.next.ts
- useRouteParams.ts
- useRouteParams.types.ts
- Deeper walkthrough:
useRouter,useRouteParams,Link
Universal Image
- Image.expo.tsx
- Image.next.tsx
- Image.tsx
- Image.types.ts
Image.next.tsx—next/imageon webImage.expo.tsx—expo-imageon native- Details:
Image
For styling those primitives with Tailwind, see Write-once Universal UI.
Adding another environment
Add YourThing.<environment>.ts(x) that satisfies the shared types, then pass it through the appropriate prop on UniversalAppProviders (see the per-feature docs above for the exact prop name).
Further watching
Broader context on the same ideas (not specific to this repo):
Related
- Cross-Platform Routing — workspace-defined routes and generators
- Write-once Universal UI — Nativewind,
Image, and theme useRouter—contextRouter,UniversalAppProviders, andCoreContextwiring
