Setting Up a React Native + Web Monorepo: A Practical Guide
Building apps that work across mobile (iOS/Android) and web with shared code is a common need. This guide shows you how to set up a monorepo using pnpm, Turborepo, and Expo to manage a React Native app alongside a web app with shared packages.
Why a Monorepo?
A monorepo lets you:
- Share TypeScript types, utilities, and business logic between mobile and web
- Update shared code once and have it propagate to all apps
- Maintain consistent dependencies across projects
- Build and test everything together
This setup is based partly on this repo
Project Structure
my-monorepo/
├── apps/
│ ├── mobile/ # React Native app (Expo)
│ └── web/ # Next.js or React web app
├── packages/
│ ├── types/ # Shared TypeScript types
│ ├── utils/ # Shared utilities
│ ├── ui-mobile/ # React Native components
│ └── ui-web/ # React web components
├── package.json # Root package.json
├── pnpm-workspace.yaml # pnpm workspace config
├── turbo.json # Turborepo config
└── .npmrc # pnpm configuration
Step 1: Initialize the Monorepo
# Create project directory
mkdir my-monorepo && cd my-monorepo
# Initialize root package.json
pnpm init
# Create workspace structure
mkdir -p apps packages
mkdir -p apps/mobile apps/web
mkdir -p packages/types packages/utils
Step 2: Configure pnpm Workspace
Create pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
Create .npmrc for React Native compatibility:
# Use hoisted node-linker for Metro bundler compatibility
node-linker=hoisted
# Enable shamefully-hoist for React Native/Expo
shamefully-hoist=true
# Auto-install peer dependencies
auto-install-peers=true
# Deduplicate dependencies
dedupe-injected-deps=true
Important: The hoisted structure is critical for React Native's Metro bundler to resolve dependencies correctly.
Step 3: Setup Turborepo
Install Turborepo:
pnpm add -D turbo -w
Create turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**", ".expo/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
}
}
}
Update root package.json:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@9.0.0"
}
Step 4: Create Shared Packages
packages/types/package.json
{
"name": "@my-app/types",
"version": "0.1.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
packages/types/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
packages/types/src/index.ts
export interface User {
id: string;
name: string;
email: string;
}
export type Language = 'en' | 'de' | 'fr';
Step 5: Setup React Native App (Expo)
cd apps/mobile
pnpx create-expo-app@latest . --template blank-typescript
Update apps/mobile/package.json:
{
"name": "@my-app/mobile",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"build": "expo export --output-dir ./build --platform all",
"android": "expo start --android",
"ios": "expo start --ios"
},
"dependencies": {
"@my-app/types": "workspace:*",
"@my-app/utils": "workspace:*",
"expo": "^54.0.0",
"expo-router": "~6.0.0",
"react": "19.1.0",
"react-native": "0.81.5"
}
}
Critical: Metro Config for Monorepo
Create apps/mobile/metro.config.js:
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// Watch entire monorepo
config.watchFolders = [monorepoRoot];
// Resolve from both app and monorepo node_modules
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
// Required for monorepo
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
Step 6: Setup Web App (Next.js)
cd apps/web
pnpx create-next-app@latest . --typescript --tailwind --app
Update apps/web/package.json:
{
"name": "@my-app/web",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@my-app/types": "workspace:*",
"@my-app/utils": "workspace:*",
"next": "14.0.0",
"react": "19.1.0",
"react-dom": "19.1.0"
}
}
Configure Next.js for Monorepo
Update apps/web/next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@my-app/types', '@my-app/utils'],
};
module.exports = nextConfig;
Step 7: Handle Version Conflicts
Add to root package.json to enforce consistent versions:
{
"pnpm": {
"overrides": {
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5"
}
}
}
Step 8: Install Dependencies
# From root directory
pnpm install
Step 9: Setup EAS Build for React Native
Install EAS CLI:
pnpm add -g eas-cli
Login and configure:
cd apps/mobile
eas login
eas build:configure
Create apps/mobile/eas.json:
{
"build": {
"base": {
"pnpm": "9.0.0",
"node": "20.0.0",
"env": {
"EXPO_USE_FAST_RESOLVER": "true"
}
},
"production": {
"extends": "base",
"distribution": "store"
}
}
}
Add Build Script to Mobile App
Update apps/mobile/package.json:
{
"scripts": {
"eas-build-post-install": "pnpm run -w build:mobile"
}
}
Add to root package.json:
{
"scripts": {
"build:mobile": "turbo run build --filter=\"...{./apps/mobile}\""
}
}
Common Issues & Solutions
Issue 1: "Unable to resolve module"
Cause: Metro bundler can't find dependencies in hoisted node_modules
Solution: Ensure .npmrc has node-linker=hoisted and shamefully-hoist=true
Issue 2: Duplicate Dependencies
Cause: Multiple versions of React/React Native installed
Solution: Use pnpm overrides in root package.json:
{
"pnpm": {
"overrides": {
"react": "19.1.0",
"react-dom": "19.1.0"
}
}
}
Issue 3: EAS Build Fails with "workspace:*"
Cause: npm doesn't understand pnpm workspace protocol
Solution:
- Never commit
package-lock.jsonfiles - Add to
.gitignore:
package-lock.json
yarn.lock
Issue 4: Metro Config Warning
Warning: resolver.disableHierarchicalLookup mismatch
Solution: This is expected and required for monorepo. The setting ensures Metro resolves packages from the workspace root correctly.
Environment Variables for Mobile
Local Development
Create apps/mobile/.env.local:
EXPO_PUBLIC_API_URL=http://localhost:3000
Production Builds
Set environment variables via EAS:
cd apps/mobile
eas env:create \
--scope project \
--name EXPO_PUBLIC_API_URL \
--value https://api.myapp.com \
--environment production
Using Shared Code
In Mobile App
// apps/mobile/app/index.tsx
import { User } from '@my-app/types';
import { formatDate } from '@my-app/utils';
export default function HomeScreen() {
const user: User = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
};
return <Text>{user.name}</Text>;
}
In Web App
// apps/web/app/page.tsx
import { User } from '@my-app/types';
import { formatDate } from '@my-app/utils';
export default function Home() {
const user: User = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
};
return <div>{user.name}</div>;
}
Running the Apps
# Run both apps in parallel
pnpm dev
# Run only mobile app
pnpm --filter @my-app/mobile dev
# Run only web app
pnpm --filter @my-app/web dev
# Build everything
pnpm build
# Build only mobile dependencies
pnpm run build:mobile
Project Scripts Summary
| Command | Description |
|---|---|
pnpm dev | Start all apps in development mode |
pnpm build | Build all packages and apps |
pnpm lint | Lint all packages |
pnpm test | Run tests across all packages |
pnpm --filter @my-app/mobile start | Start mobile app |
pnpm --filter @my-app/web dev | Start web app |
Best Practices
- Keep workspace packages simple - Don't over-engineer shared packages
- Use TypeScript consistently - Type safety across the monorepo
- Version pinning - Use exact versions for React/React Native to avoid conflicts
- Turborepo caching - Speeds up builds significantly
- Clear package boundaries - Mobile UI components in
ui-mobile, web inui-web - Environment variables - Use EAS env for production,
.env.localfor development
Gotchas to Avoid
- Don't mix package managers - Stick with pnpm, remove
package-lock.json - Don't skip Metro config - Metro must be configured for monorepo
- Don't forget EAS build hook -
eas-build-post-installmust build dependencies - Don't hardcode URLs - Use environment variables for API endpoints
- Don't forget .npmrc - Required for Metro bundler compatibility
Conclusion
This setup gives you:
- ✅ Shared code between mobile and web
- ✅ Fast builds with Turborepo caching
- ✅ Type safety with TypeScript
- ✅ Easy dependency management with pnpm
- ✅ Production-ready builds with EAS
The key to success is understanding that React Native's Metro bundler needs special configuration in a monorepo environment. The hoisted node_modules structure and proper Metro config are essential.
Next Steps
Once you have the base setup working:
- Add ESLint and Prettier for code quality
- Set up CI/CD with GitHub Actions
- Add Sentry for error tracking
- Implement shared API clients
- Create platform-specific UI component libraries
Resources
Happy coding! If you found this guide helpful, please share it with others building cross-platform apps. 🚀
Found a problem or running into issue? Drop me a line via email: pnhoang@gmail.com
