Building Cross-Platform Apps: Sharing Business Logic Between Web, Mobile, and Desktop

"Write once, run everywhere" has been a promise in software for decades, and it's never fully true — but the gap between the marketing version and the achievable version has narrowed enough that, done deliberately, 60–80% of a typical product's business logic (not UI) can genuinely live in one shared package across web, mobile, and desktop. The mistake is aiming for shared UI code across platforms, which produces the worst version of every platform's UX. The right target is shared logic with platform-native presentation on top.
Draw the line at the UI boundary, not the platform boundary
The useful mental model isn't "web code vs. mobile code" — it's "presentation vs. everything else." Everything else — API clients, data validation, business rules (pricing logic, permission checks, form validation rules), state management for domain data, date/currency formatting, caching strategy — has no inherent dependency on whether it's rendered by a <div>, a React Native View, or an Electron window. That's the code that belongs in a shared package. Presentation — layout, navigation patterns, platform-specific interactions (swipe gestures on mobile, keyboard shortcuts on desktop, hover states on web) — should stay platform-specific, because trying to abstract it produces an interface that feels foreign on every platform simultaneously.
React and React Native make this boundary unusually clean because they share a component model and, with care, a state-management approach — a Zustand store or a set of custom hooks encapsulating "how do I fetch and cache this user's orders" can be nearly identical code on both, called from entirely different presentation components. Desktop, via Electron (which is really just a web view with native OS APIs bolted on), can reuse the web app's presentation layer directly in many cases, which means the shared-logic package built for React and React Native mostly comes along for free.
The shared package, concretely
We structure this as a monorepo (Turborepo or Nx — both work; we lean Turborepo for its simplicity when the number of packages is modest) with a package layout roughly like:
packages/core— domain types (TypeScript interfaces for every entity in the system), validation schemas (Zod, shared between client-side form validation and, ideally, the backend's own validation), and pure business-logic functions with no platform dependency at all.packages/api-client— a typed API client generated from or matching the backend's schema, with request/response types imported fromcore. One implementation, one place a breaking API change gets caught at compile time across every platform that depends on it.packages/state— shared hooks and stores for domain state (not UI state — a modal's open/closed state stays local to its platform-specific component).apps/web,apps/mobile,apps/desktop— thin platform shells that import from the packages above and are responsible only for presentation and platform-specific integration (push notification registration on mobile, native menu bars on desktop).
The test of whether this boundary is drawn correctly: if a product manager asks "why does the discount code validation behave differently on mobile than on web," the answer should always be "it doesn't, they call the same function" — and if it isn't, that's a bug in the shared package, not a per-platform inconsistency to patch twice.
Where NOT to share code
Three categories consistently don't belong in the shared layer, and forcing them there is the most common way these architectures go wrong.
Navigation and routing. Web routing (URL-based, back-button semantics, deep linking via paths) and mobile navigation (stack/tab-based, no URL bar) are different enough conceptually that a shared abstraction over both ends up satisfying neither well. Keep navigation platform-native.
Anything performance-critical enough to need native code. Camera processing, complex animations, heavy on-device computation — React Native's bridge (or even the newer JSI-based architecture) adds overhead that matters for these specific cases. Drop to a native module for that narrow piece of functionality and keep the shared abstraction around it, rather than forcing the performance-critical logic itself into the shared, cross-platform layer.
Platform-specific UX conventions. A confirmation pattern that's a modal on web is often better as a native action sheet on iOS and a different pattern again on Android — respecting platform conventions matters more to users than internal code-reuse metrics do, and product teams that force one interaction pattern across all platforms for consistency's sake usually pay for it in usability, not just aesthetics.
Shared code should be invisible to the user. The moment "code reuse" produces a UI that feels wrong on a platform, the abstraction has been drawn in the wrong place.
Tooling choices that actually matter
TypeScript across every package, with strict mode on, is close to non-negotiable for this to work — the entire value proposition of a shared core package collapses if a breaking type change in a domain model doesn't get caught at compile time in the mobile app that imports it. A monorepo build tool (Turborepo/Nx) that caches builds and only rebuilds what actually changed is what keeps this fast enough to not become its own tax on iteration speed — without it, a small change to a leaf package can trigger a full rebuild of every app that depends on it, and CI times balloon.
Design tokens (colors, spacing, typography scale) are worth sharing as a package too, even though the components that consume them are platform-specific — it's a smaller, lower-risk version of the same principle: share the parts with no inherent platform dependency, keep the parts that do have one native to each platform.
Done this way, adding a new platform later — a third-party integration, a smart-TV app, a browser extension — is a matter of building a new thin presentation shell against an already-proven shared core, not another full reimplementation of business logic that then quietly drifts from the other two.