Sharing Code with a Web App
A monorepo that shares types and logic between a React Native app and a React web app, with platform code kept at the edges.
import { cartReducer, cartTotalPence, type CartLine } from "core";Most of a product is not platform code. Types, validation, API calls, pricing rules, reducers: a React web app and a React Native app can run the same copy. A monorepo is how that happens. One repository holds apps/mobile and apps/web beside packages/core, and the package manager's workspaces feature links them so import { cartTotalPence } from "core" resolves to local source rather than a published package.
What shares and what does not
Shareable: plain TypeScript, and React itself - hooks, context, reducers. Not shareable: anything that draws. View and Text are not div and span, StyleSheet is not CSS, and there is no cascade for a shared stylesheet to rely on. Navigation differs. Storage differs, since the phone has AsyncStorage and the browser has localStorage. Native modules have no browser equivalent at all. Declare those as a small interface in the shared package and let each app supply its own implementation: the port stays in packages/core, the adapters live in the apps.
Setup, and the versions that matter
Since SDK 52, expo/metro-config detects a monorepo, so the watchFolders and resolver.nodeModulesPaths patches that older guides show are no longer needed. From SDK 55, autolinking of native modules is enabled for monorepo apps automatically. Type-only imports keep types flowing without code: import { type Cart } from "core" and import type { Cart } from "core" are the same thing, and both vanish at build time.
What trips people
One react version across the whole workspace. Two copies produce hook errors that look like your own bug.
Example
// packages/core/src/cart.ts
// Plain TypeScript: no react-native import, so the web app can use it too
export type CartLine = { sku: string; unitPence: number; quantity: number };
export type CartAction =
| { type: "add"; line: CartLine }
| { type: "remove"; sku: string };
export function cartReducer(lines: CartLine[], action: CartAction): CartLine[] {
switch (action.type) {
case "add": {
const existing = lines.find((line) => line.sku === action.line.sku);
if (!existing) return [...lines, action.line];
return lines.map((line) =>
line.sku === action.line.sku
? { ...line, quantity: line.quantity + action.line.quantity }
: line
);
}
case "remove":
return lines.filter((line) => line.sku !== action.sku);
}
}
export function cartTotalPence(lines: CartLine[]): number {
return lines.reduce((total, line) => total + line.unitPence * line.quantity, 0);
}
const lines = cartReducer([], {
type: "add",
line: { sku: "mug", unitPence: 950, quantity: 2 },
});
console.log(cartTotalPence(lines));
// Output: 1900When to use it
- Keeping one definition of an API response so a backend change breaks both clients at compile time.
- Reusing pricing, tax or validation rules between a web checkout and the mobile app.
- Letting a web team and a mobile team ship from one repository without copying files.
- Testing business logic once, in Node, instead of once per platform.
More examples
Workspace layout
Both apps list core as a dependency and the workspace links it to the folder on disk, so editing shared code updates both apps with no publish step. Create the mobile app in place with a command such as npx create-expo-app@latest apps/mobile; SDK 52 and later need no Metro changes to find packages/core.
package.json "workspaces": ["apps/*", "packages/*"]
apps/
mobile/ the Expo app - app.json, metro.config.js, src/app
package.json depends on "core": "*"
web/ the React web app
package.json depends on "core": "*"
packages/
core/
src/cart.ts pricing rules, reducers
src/api.ts fetch calls and response types
src/storage.ts an interface, not an implementation
package.json name: "core", main: "src/index.ts"A port in the package, adapters in the apps
Shared code takes a Storage and never learns which one it got, so the one piece that genuinely differs stays three lines long per platform. This is the pattern for every platform boundary: storage, analytics, file access, biometrics.
// packages/core/src/storage.ts - the port: shared and platform-free
export type Storage = {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
// apps/mobile/src/lib/storage.ts - the native adapter
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { Storage } from "core";
export const storage: Storage = {
get: (key) => AsyncStorage.getItem(key),
set: (key, value) => AsyncStorage.setItem(key, value),
};
// apps/web/src/lib/storage.ts - the browser adapter
import type { Storage } from "core";
export const storage: Storage = {
get: async (key) => window.localStorage.getItem(key),
set: async (key, value) => window.localStorage.setItem(key, value),
};The mobile app consuming shared logic
On screen: a total above a blue button, and each tap raises the total by £9.50. Only the JSX and the styles are mobile-specific - the arithmetic and the action shapes are the shared package. Note the inline type import, which is the same as a separate import type line.
// apps/mobile/src/app/cart.tsx
import { useReducer } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { cartReducer, cartTotalPence, type CartLine } from "core";
const mug: CartLine = { sku: "mug", unitPence: 950, quantity: 1 };
export default function CartScreen() {
// useReducer takes the shared reducer unchanged; the web app calls the same one
const [lines, dispatch] = useReducer(cartReducer, [] as CartLine[]);
return (
<View style={styles.screen}>
<Text style={styles.total}>
{`Total £${(cartTotalPence(lines) / 100).toFixed(2)}`}
</Text>
<Pressable style={styles.button} onPress={() => dispatch({ type: "add", line: mug })}>
<Text style={styles.buttonLabel}>Add mug</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 16 },
total: { fontSize: 24, fontWeight: "600" },
button: { padding: 14, borderRadius: 10, backgroundColor: "#1d4ed8" },
buttonLabel: { color: "#ffffff", textAlign: "center" },
});
Discussion