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:

  1. Never commit package-lock.json files
  2. 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

CommandDescription
pnpm devStart all apps in development mode
pnpm buildBuild all packages and apps
pnpm lintLint all packages
pnpm testRun tests across all packages
pnpm --filter @my-app/mobile startStart mobile app
pnpm --filter @my-app/web devStart web app

Best Practices

  1. Keep workspace packages simple - Don't over-engineer shared packages
  2. Use TypeScript consistently - Type safety across the monorepo
  3. Version pinning - Use exact versions for React/React Native to avoid conflicts
  4. Turborepo caching - Speeds up builds significantly
  5. Clear package boundaries - Mobile UI components in ui-mobile, web in ui-web
  6. Environment variables - Use EAS env for production, .env.local for development

Gotchas to Avoid

  1. Don't mix package managers - Stick with pnpm, remove package-lock.json
  2. Don't skip Metro config - Metro must be configured for monorepo
  3. Don't forget EAS build hook - eas-build-post-install must build dependencies
  4. Don't hardcode URLs - Use environment variables for API endpoints
  5. 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

Setting Up a React Native + Web Monorepo: A Practical Guide - Hoang Pham