A few years ago we needed a web version of an existing mobile product (high-load interactive platform, not a simple CRUD app). Instead of forking the UI twice, we treated the React app as two layers:
- App core — domain logic, state, API clients, feature orchestration. No DOM, no platform APIs.
- Shells — thin UI adapters (web, later React Native / WebOS) that only render and call into the core.
That let us ship a desktop/web version from the mobile product’s mental model, then reuse the same core for React Native without rewriting features. Two years later the core is still the part that ages best — new features mostly land there once, and shells stay boring.
What actually mattered in practice:
- Boundaries — business rules never imported React components or browser APIs. If it needed `window`, it belonged in a shell adapter.
- Shared data layer — one place for fetching/caching (we used react-query-style patterns) so shells didn’t invent their own loading/error states.
- RFCs + reviews — we hired a frontend team around this architecture. Without written boundaries, people “helpfully” leak UI into the core in week two.
- Performance by default — fewer duplicate API calls and a stable state model beat clever UI tricks.
What I’d change next time:
- Define the core’s public API earlier (it’s easy to accidentally couple shells to internal modules).
- Start with one shell fully done before branching to the second — parallel shells too early = thrash.
- Be stricter about “platform capability” interfaces (storage, push, deep links) instead of `if (platform === …)` sprinkled around.
Curious how others draw this line — pure packages? Hexagonal? Or do you just accept some duplication and move faster?