Mobile — Cross Platform Development
React Native + Next.js Shared Code Patterns
Direct answer
The pattern that works in production is a monorepo where React Native (Expo) and Next.js apps consume shared packages containing business logic, API clients, validation schemas, and state stores — all plain TypeScript with no platform imports — while each app keeps its own UI layer. Sharing logic typically covers a large share of the interesting code; sharing rendered components via react-native-web is optional and I only do it for genuinely app-like web products. The critical plumbing is Metro configuration for workspace resolution and strict boundaries on what shared packages may import.
Most teams running both a React Native app and a Next.js site rebuild the same API calls, validation, and state logic twice, then watch the two drift apart. I set up monorepos that share the boring-but-critical code once while letting each platform own its UI — here are the exact patterns and configs I use.
Key facts, with sources
- In the Stack Overflow 2024 Developer Survey, Flutter was used by 9.4% of developers versus React Native's 8.4%, and among professional developers the gap nearly vanishes at 9.21% versus 9.14%. (Nomtek (citing Stack Overflow Developer Survey))
- Among developers who build cross-platform, Statista-based figures put Flutter at about 46% adoption versus 35% for React Native, with the two frameworks together dominating the cross-platform market. (Tech-Insider)
- Instagram shares 85 to 99% of its code between iOS and Android using React Native, and Shopify reports 86% unified code across its app portfolio. (CatDoes)
- According to Flutter.dev data, nearly 30% of new free iOS apps submitted to the App Store in 2025 were built with Flutter, up from roughly 10% in 2021. (Droids on Roids (citing Flutter.dev))
- The official React Native showcase lists production apps from Meta, Microsoft, Shopify, and Amazon, including desktop targets like Messenger Desktop and Microsoft apps on Windows and macOS. (React Native Showcase)
The monorepo layout that holds up
I structure these as a pnpm or yarn workspace with two app folders and a handful of packages: apps/mobile (Expo), apps/web (Next.js), packages/api (typed client and endpoint definitions), packages/core (domain logic, validation, formatting), and packages/config (shared TypeScript and ESLint configs). Turborepo on top handles task orchestration and caching.
The rule that keeps this healthy: packages/core and packages/api must not import react-native, next, or anything DOM-specific. They may import react (for hooks) and universal libraries like zod or date-fns. I enforce this with ESLint import restrictions rather than trusting convention, because the first developer under deadline pressure will otherwise import a native module into shared code and break the web build in a way nobody understands for an afternoon.
What to share — and what to deliberately duplicate
High-value shared code: the API client with request and response types, zod schemas used for both form validation and API parsing, currency and date formatting, feature-flag evaluation, analytics event definitions, and state stores. This is the code where drift between platforms causes real bugs — a discount calculated differently on web and mobile is a support ticket factory.
What I deliberately duplicate: screens, navigation, and most components. Web and mobile have different interaction models — hover, right-click, and keyboard on web; gestures, safe areas, and native transitions on mobile — and forcing one component tree to serve both usually produces a mediocre experience on each. Duplicating a JSX layout is cheap when all the logic behind it lives in a shared hook. My rough rule: share everything you can test without rendering, duplicate most things you can't.
Shared state and hooks across platforms
Zustand is my default here because a store is plain TypeScript — no provider tree, no platform bindings — so the identical store file runs in Expo and Next.js. Server state goes through TanStack Query with shared query-key factories and fetcher functions in packages/api, while each app instantiates its own QueryClient with platform-appropriate settings (mobile gets more aggressive refetch-on-reconnect, for example).
Persistence is the one place the platforms must diverge, and I handle it by injection: the shared store accepts a storage adapter, mobile passes an AsyncStorage or MMKV-backed one, web passes localStorage. The store logic never knows which platform it is on.
import { create } from 'zustand';
import { persist, createJSONStorage, type StateStorage } from 'zustand/middleware';
interface SessionState {
userId: string | null;
plan: 'free' | 'pro';
setSession: (userId: string, plan: 'free' | 'pro') => void;
clear: () => void;
}
// Each app injects its own storage: MMKV/AsyncStorage on mobile, localStorage on web.
export const createSessionStore = (storage: StateStorage) =>
create<SessionState>()(
persist(
(set) => ({
userId: null,
plan: 'free',
setSession: (userId, plan) => set({ userId, plan }),
clear: () => set({ userId: null, plan: 'free' }),
}),
{ name: 'session', storage: createJSONStorage(() => storage) },
),
);Metro configuration: where monorepos actually break
Next.js handles workspace packages almost transparently (transpilePackages covers the rest), but Metro is where React Native monorepos go to die. Metro needs to be told to watch the workspace root and to resolve modules from both the app's node_modules and the hoisted root. Expo's default config handles much of this now, but I still set it explicitly so behavior survives dependency hoisting changes.
The other recurring failure is duplicate React copies — if a shared package declares react as a dependency instead of a peerDependency, you get the invalid-hook-call error that costs teams a day the first time they see it. Every shared package should list react and react-native as peerDependencies only.
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// Watch the whole monorepo so edits in packages/* trigger rebuilds.
config.watchFolders = [workspaceRoot];
// Resolve from the app first, then the hoisted workspace root.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;Sharing UI: only where it earns its keep
Full component sharing via react-native-web is a real option, and frameworks in the Expo ecosystem make universal apps increasingly practical. But I treat it as an opt-in for specific surfaces, not a default. Good candidates: a design-token package (colors, spacing, typography as plain constants), small stateless primitives like badges and avatars, and app-like authenticated dashboards where web is essentially a big-screen version of the app.
Poor candidates: marketing pages, SEO-critical content, and anything needing rich web-native behavior like complex tables or drag-and-drop. The failure mode I see in audits is a team three months into building a universal component library who could have shipped both apps by duplicating thirty components backed by shared hooks. Share the tokens and the logic first; promote components to universal only when the duplication demonstrably hurts.
When to hire senior help
The framework decision is a one-way door worth a short senior consultation, because switching stacks after a year of development is effectively a rewrite. A senior cross-platform engineer can also audit whether your planned feature set has hidden native-heavy corners, like payments hardware or background location, that change the cost math before you commit. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.
Bottom line
Dhairya Senjaliya ships Mobile — Cross Platform Development projects worldwide — book a scoping call to discuss your specific situation.
Common pitfalls to avoid
- ✕Picking the framework by hype rather than team composition, such as a React web team adopting Flutter and forfeiting years of transferable JavaScript expertise
- ✕Budgeting for 100% code reuse and zero platform-specific time, then discovering payments, background tasks, and widgets all require per-platform native work
- ✕Recreating navigation and interaction patterns generically so iOS swipe-back gestures and Android back-button behavior both feel wrong to native users
- ✕Ignoring the maintenance tail: never budgeting for framework major-version upgrades, so the app rots on an unsupported runtime within two years
Frequently asked questions
Can React Native and Next.js share the same components?
Yes, via react-native-web, but it is only worth it for app-like surfaces such as authenticated dashboards and small stateless primitives. Marketing pages and SEO-heavy content are better built with regular web components. Most production teams get more value from sharing business logic, API clients, and state stores — plain TypeScript that runs anywhere — while keeping each platform's UI native to it.
What is the best way to share code between a mobile app and a website?
Use a monorepo (pnpm or yarn workspaces, typically with Turborepo) containing your Expo app, Next.js app, and shared packages for API clients, validation schemas, domain logic, and state stores. Keep shared packages free of react-native and DOM imports, enforce that with lint rules, and list react as a peerDependency so both apps use one copy.
Why does my React Native monorepo fail to resolve shared packages?
It is almost always Metro configuration. Metro must watch the workspace root and resolve modules from both the app's node_modules and the hoisted root node_modules — set watchFolders and resolver.nodeModulesPaths explicitly. The other common cause is a shared package declaring react as a direct dependency, creating duplicate React copies and the classic invalid-hook-call error.
Should we choose Flutter or React Native?
Adoption among professional developers is nearly tied (9.21% versus 9.14% in the Stack Overflow 2024 survey), so the deciding factor is your team: React or JavaScript experience strongly favors React Native, while a greenfield team with no web-code-sharing needs can prefer Flutter. Both run massive production apps, so neither choice is inherently risky.
How much money does cross-platform actually save versus two native apps?
Industry cost guides put savings around 30 to 40% versus parallel native builds, driven by one codebase and one team, with production apps sharing 85 to 95% of code. Real savings depend on how much platform-specific work your feature set demands, such as payments, widgets, and background processing.
When is fully native the better choice?
Heavy 3D or AR workloads, advanced camera or audio processing, platform-first experiences like watchOS apps, or a company that already employs strong separate iOS and Android teams. For typical business, marketplace, and content apps, cross-platform is now the default choice at both startups and large companies like Shopify and Microsoft.
Bottom line: Dhairya Senjaliya ships Mobile — Cross Platform Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.