We went through exactly this about a year ago — React web + React Native with a growing pile of duplicated logic. Here’s what worked for us.
The approach: pnpm monorepo with internal packages
The cleanest solution for a team your size is to bring both apps into a single pnpm monorepo and extract shared logic into internal workspace packages. No npm publishing needed.
Repo structure
my-project/
packages/
shared/ # shared business logic
package.json
tsconfig.json
src/
api-client.ts
validation.ts
auth.ts
types.ts
index.ts
web/ # React web app
package.json
...
mobile/ # React Native app
package.json
...
pnpm-workspace.yaml
tsconfig.base.json
1. Set up the workspace
pnpm-workspace.yaml:
packages:
- 'packages/*'
2. Create the shared package
packages/shared/package.json:
{
"name": "@myproject/shared",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
Note: We point main directly at the TypeScript source. Both the web bundler (Vite/webpack) and Metro (React Native) can consume .ts files directly, so no build step is needed for the shared package.
packages/shared/src/index.ts:
export { apiClient } from './api-client';
export { validateEmail, validatePassword } from './validation';
export { tokenManager } from './auth';
export type { User, AuthToken, ApiResponse } from './types';
3. Consume it from both apps
In packages/web/package.json and packages/mobile/package.json:
{
"dependencies": {
"@myproject/shared": "workspace:*"
}
}
Then in any component or service:
import { validateEmail, apiClient } from '@myproject/shared';
const isValid = validateEmail(input);
const user = await apiClient.getUser(userId);
4. Configure Metro for React Native
Metro (RN’s bundler) needs to know about packages outside its root. In packages/mobile/metro.config.js:
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = {
watchFolders: [monorepoRoot],
resolver: {
nodeModulesPaths: [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
],
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
Rules that keep this healthy
After doing this for a year, these rules saved us from pain:
-
No platform-specific imports in shared — no react-native, no window, no document. Pure TypeScript only. If you need platform-specific behaviour, use dependency injection or strategy patterns.
-
Test the shared package independently — run pnpm --filter @myproject/shared test in CI. This catches breakage before either app sees it.
-
Shared package gets its own tsconfig.json with strict settings. We use a base config at the root and extend it:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
- Keep the shared package thin — only move code there when both platforms actually need it. Resist the urge to make everything shared.
What didn’t work for us
- Git submodules — version drift was constant and the DX was terrible.
- Publishing to a private npm registry — added a release cycle for every small logic change. Way too heavy for a 5-person team.
- Symlinks without a monorepo tool — Metro and webpack both struggled with manual symlinks. pnpm workspaces handle this properly.
Hope this helps — feel free to ask follow-ups about CI setup or specific Metro quirks.