Skip to main content
Expo supports monorepo setups where multiple packages and apps share dependencies and code. This guide covers configuration and best practices.

Overview

A monorepo structure for Expo:
my-monorepo/
├── packages/
│   ├── shared-components/
│   │   ├── src/
│   │   └── package.json
│   └── shared-utils/
│       ├── src/
│       └── package.json
├── apps/
│   ├── mobile/
│   │   ├── app/
│   │   ├── app.json
│   │   └── package.json
│   └── admin/
│       ├── app/
│       ├── app.json
│       └── package.json
└── package.json  # Root

Workspace Managers

Yarn Workspaces

package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "mobile": "yarn workspace @myapp/mobile start",
    "build:mobile": "yarn workspace @myapp/mobile build"
  }
}

npm Workspaces

package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "mobile": "npm run start -w @myapp/mobile"
  }
}

pnpm Workspaces

pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
package.json (root)
{
  "scripts": {
    "mobile": "pnpm --filter @myapp/mobile start"
  }
}

Setting Up

1

Initialize root package

mkdir my-monorepo
cd my-monorepo
npm init -y
package.json
{
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}
2

Create Expo app

mkdir -p apps
cd apps
npx create-expo-app mobile
3

Create shared packages

mkdir -p packages/shared-components
cd packages/shared-components
npm init -y
packages/shared-components/package.json
{
  "name": "@myapp/shared-components",
  "version": "1.0.0",
  "main": "src/index.ts",
  "dependencies": {
    "react": "*",
    "react-native": "*"
  }
}
4

Link packages

apps/mobile/package.json
{
  "name": "@myapp/mobile",
  "dependencies": {
    "@myapp/shared-components": "*",
    "@myapp/shared-utils": "*"
  }
}
# Install dependencies
cd ../..
yarn install
# or: npm install
# or: pnpm install

Metro Configuration

Configure Metro to resolve workspace packages.

Basic Config

apps/mobile/metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

// Find the project root
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// Watch all files in the monorepo
config.watchFolders = [monorepoRoot];

// Resolve modules from monorepo
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
];

// Support workspace packages
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

Advanced Config

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);

// 1. Watch all workspace packages
config.watchFolders = [monorepoRoot];

// 2. Resolve modules
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
];

// 3. Disable hierarchical lookup
config.resolver.disableHierarchicalLookup = true;

// 4. Support TypeScript in workspace packages
config.resolver.sourceExts = ['js', 'jsx', 'ts', 'tsx', 'json'];

// 5. Handle symlinks (for some workspace managers)
config.resolver.resolveRequest = (context, moduleName, platform) => {
  // Let Metro handle workspace packages
  if (moduleName.startsWith('@myapp/')) {
    return context.resolveRequest(context, moduleName, platform);
  }
  return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;

TypeScript Configuration

Root Config

tsconfig.json (root)
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "jsx": "react-native",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "exclude": ["node_modules"]
}

App Config

apps/mobile/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@myapp/shared-components": ["../../packages/shared-components/src"],
      "@myapp/shared-utils": ["../../packages/shared-utils/src"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Package Config

packages/shared-components/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true
  },
  "include": ["src/**/*"]
}

Shared Packages

Component Library

packages/shared-components/src/Button.tsx
import { Pressable, Text, StyleSheet } from 'react-native';

interface ButtonProps {
  title: string;
  onPress: () => void;
}

export function Button({ title, onPress }: ButtonProps) {
  return (
    <Pressable style={styles.button} onPress={onPress}>
      <Text style={styles.text}>{title}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
  },
  text: {
    color: '#fff',
    textAlign: 'center',
    fontWeight: '600',
  },
});
packages/shared-components/src/index.ts
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';

Utility Library

packages/shared-utils/src/format.ts
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}

export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('en-US').format(date);
}
packages/shared-utils/src/index.ts
export * from './format';
export * from './validation';

Using Shared Code

apps/mobile/app/index.tsx
import { Button } from '@myapp/shared-components';
import { formatCurrency } from '@myapp/shared-utils';

export default function HomeScreen() {
  const price = formatCurrency(99.99);
  
  return (
    <View>
      <Text>Price: {price}</Text>
      <Button title="Buy Now" onPress={() => {}} />
    </View>
  );
}

Native Modules in Monorepos

Autolinking

Expo modules need special handling:
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);

config.watchFolders = [monorepoRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
];

// Important for native modules
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

Custom Native Modules

packages/
└── my-native-module/
    ├── android/
    ├── ios/
    ├── src/
    ├── expo-module.config.json
    └── package.json
packages/my-native-module/package.json
{
  "name": "@myapp/my-native-module",
  "version": "1.0.0",
  "main": "src/index.ts",
  "expo": {
    "platforms": ["ios", "android"]
  }
}

Building and Deployment

Local Builds

# From root
yarn workspace @myapp/mobile run ios
yarn workspace @myapp/mobile run android

# Or from app directory
cd apps/mobile
npx expo run:ios
npx expo run:android

EAS Build

EAS Build automatically supports monorepos:
apps/mobile/eas.json
{
  "build": {
    "development": {
      "developmentClient": true
    },
    "production": {}
  }
}
cd apps/mobile
eas build --platform ios

CI/CD

.github/workflows/build.yml
name: Build Mobile App

on:
  push:
    paths:
      - 'apps/mobile/**'
      - 'packages/**'

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: apps/mobile
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies (root)
        run: |
          cd ../..
          yarn install
      
      - name: Build
        run: npx expo export

Troubleshooting

Metro Can’t Resolve Module

Error: Unable to resolve module @myapp/shared-components
Solution:
# Clear Metro cache
npx expo start --clear

# Reinstall dependencies
rm -rf node_modules
yarn install

Duplicate Module in Graph

Error: Duplicate module in graph: react-native
Solution:
metro.config.js
config.resolver.resolveRequest = (context, moduleName, platform) => {
  if (moduleName === 'react-native') {
    return {
      filePath: path.resolve(projectRoot, 'node_modules/react-native/index.js'),
      type: 'sourceFile',
    };
  }
  return context.resolveRequest(context, moduleName, platform);
};

Native Module Not Found

Error: Native module 'ExpoCamera' is not available
Solution:
# Install native modules in app directory
cd apps/mobile
npx expo install expo-camera

# NOT in packages

Build Fails: Package Not Found

# Ensure all workspace packages are built
cd packages/shared-components
npm run build

# Or add prepare script in root
"scripts": {
  "prepare": "yarn workspaces foreach -A run build"
}

Best Practices

1. Use Path Aliases

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@myapp/*": ["packages/*/src"]
    }
  }
}

2. Shared ESLint Config

.eslintrc.js (root)
module.exports = {
  extends: ['expo', 'prettier'],
  rules: {
    // Shared rules
  },
};
apps/mobile/.eslintrc.js
module.exports = {
  extends: ['../../.eslintrc.js'],
};

3. Hoisted Dependencies

package.json (root)
{
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/react": "^18.0.0",
    "eslint": "^8.0.0"
  }
}

4. Build Scripts

package.json (root)
{
  "scripts": {
    "build:packages": "yarn workspaces foreach -A --exclude @myapp/mobile run build",
    "dev:mobile": "yarn workspace @myapp/mobile start",
    "test": "yarn workspaces foreach -A run test",
    "lint": "yarn workspaces foreach -A run lint"
  }
}

Next Steps

Prebuild

Generate native projects in monorepos

Build Properties

Configure builds

Native Modules

Create shared native modules

Testing

Test across packages

Build docs developers (and LLMs) love