Skip to main content

Overview

Monorepos allow you to manage multiple packages in a single repository. This is useful for sharing code between your Expo app, web app, and shared packages.

Common Monorepo Structure

my-monorepo/
├── apps/
│   ├── mobile/              # Expo app
│   │   ├── app/
│   │   ├── app.json
│   │   └── package.json
│   └── web/                 # Next.js/web app
│       ├── pages/
│       └── package.json
├── packages/
│   ├── ui/                  # Shared UI components
│   │   ├── src/
│   │   └── package.json
│   ├── api/                 # API client
│   │   ├── src/
│   │   └── package.json
│   └── config/              # Shared configs
│       └── package.json
├── package.json             # Root package.json
└── tsconfig.json            # Base TypeScript config

Setup with Yarn Workspaces

1

Initialize the monorepo

mkdir my-monorepo
cd my-monorepo
yarn init -y
2

Configure workspaces

package.json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "mobile": "yarn workspace @my-app/mobile",
    "web": "yarn workspace @my-app/web",
    "dev": "yarn workspaces foreach -pi run dev"
  }
}
3

Create the Expo app

mkdir -p apps/mobile
cd apps/mobile
npx create-expo-app@latest . --template blank
Update apps/mobile/package.json:
{
  "name": "@my-app/mobile",
  "version": "1.0.0",
  "main": "expo-router/entry",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  }
}
4

Create shared packages

mkdir -p packages/ui
cd packages/ui
yarn init -y
packages/ui/package.json
{
  "name": "@my-app/ui",
  "version": "1.0.0",
  "main": "src/index.ts",
  "dependencies": {
    "react": "*",
    "react-native": "*"
  }
}
5

Link packages

In apps/mobile/package.json:
{
  "dependencies": {
    "@my-app/ui": "*",
    "@my-app/api": "*"
  }
}
Then run:
cd ../.. # Back to root
yarn install

Setup with pnpm

1

Initialize with pnpm

pnpm init
2

Create pnpm-workspace.yaml

pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
3

Configure .npmrc

.npmrc
node-linker=hoisted
public-hoist-pattern[]=*expo*
public-hoist-pattern[]=*react-native*
public-hoist-pattern[]=@react-native/*
public-hoist-pattern[]=metro*
React Native and Expo require hoisting due to their native module resolution.
4

Install dependencies

pnpm install

Setup with npm Workspaces

package.json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "mobile": "npm run start --workspace=@my-app/mobile",
    "web": "npm run dev --workspace=@my-app/web"
  }
}

Metro Configuration

Configure Metro for monorepos

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

// Find the project and workspace directories
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];

// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;

module.exports = config;

For Yarn 2+ (Berry)

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

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

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

// Handle PnP resolution
config.resolver.extraNodeModules = new Proxy(
  {},
  {
    get: (target, name) => {
      return path.join(workspaceRoot, `node_modules/${name}`);
    },
  }
);

module.exports = config;

TypeScript Configuration

Root tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "lib": ["esnext"],
    "jsx": "react-native",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@my-app/*": ["packages/*/src"]
    }
  }
}

App-specific tsconfig

apps/mobile/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./"
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Package tsconfig

packages/ui/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Shared Package Examples

UI Component Package

packages/ui/src/Button.tsx
import { TouchableOpacity, Text, StyleSheet } from 'react-native';

type ButtonProps = {
  title: string;
  onPress: () => void;
  variant?: 'primary' | 'secondary';
};

export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
  return (
    <TouchableOpacity 
      style={[styles.button, styles[variant]]} 
      onPress={onPress}
    >
      <Text style={styles.text}>{title}</Text>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  button: {
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  primary: {
    backgroundColor: '#007AFF',
  },
  secondary: {
    backgroundColor: '#8E8E93',
  },
  text: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
});
packages/ui/src/index.ts
export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';

API Client Package

packages/api/src/client.ts
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com';

export class ApiClient {
  private baseURL: string;

  constructor(baseURL: string = API_URL) {
    this.baseURL = baseURL;
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseURL}${endpoint}`);
    if (!response.ok) throw new Error('Network response was not ok');
    return response.json();
  }

  async post<T>(endpoint: string, data: unknown): Promise<T> {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error('Network response was not ok');
    return response.json();
  }
}

export const api = new ApiClient();

Shared Config Package

packages/config/src/theme.ts
export const theme = {
  colors: {
    primary: '#007AFF',
    secondary: '#5856D6',
    success: '#34C759',
    danger: '#FF3B30',
    warning: '#FF9500',
    text: '#000000',
    background: '#FFFFFF',
  },
  spacing: {
    xs: 4,
    sm: 8,
    md: 16,
    lg: 24,
    xl: 32,
  },
  borderRadius: {
    sm: 4,
    md: 8,
    lg: 16,
  },
} as const;

export type Theme = typeof theme;

Using Shared Packages

apps/mobile/app/index.tsx
import { Button } from '@my-app/ui';
import { api } from '@my-app/api';
import { theme } from '@my-app/config';

export default function HomeScreen() {
  const handlePress = async () => {
    const data = await api.get('/users');
    console.log(data);
  };

  return (
    <View style={{ backgroundColor: theme.colors.background }}>
      <Button title="Fetch Users" onPress={handlePress} />
    </View>
  );
}

Running Commands

# Run command in specific workspace
yarn workspace @my-app/mobile start
yarn workspace @my-app/mobile ios

# Run command in all workspaces
yarn workspaces run test

# Add dependency to specific workspace
yarn workspace @my-app/mobile add react-native-maps

# Add dev dependency to root
yarn add -DW typescript

EAS Build Configuration

apps/mobile/eas.json
{
  "cli": {
    "version": ">= 5.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "android": {
        "buildType": "apk"
      }
    },
    "production": {}
  },
  "submit": {
    "production": {}
  }
}

.easignore

Prevent uploading unnecessary files:
apps/mobile/.easignore
../../node_modules
../../apps/web
../../packages/*/node_modules
*.log
.git

Troubleshooting

Ensure your metro.config.js is configured correctly:
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
Clear Metro cache:
yarn workspace @my-app/mobile start --clear
Native modules must be installed in the app’s node_modules:
# Install in app, not shared package
yarn workspace @my-app/mobile add react-native-maps
Or hoist with pnpm:
.npmrc
public-hoist-pattern[]=*react-native*
Check your tsconfig.json paths:
{
  "compilerOptions": {
    "paths": {
      "@my-app/*": ["packages/*/src"]
    }
  }
}
Restart TypeScript server in your editor.
Ensure .easignore doesn’t exclude workspace packages:
# Don't do this:
# ../../packages

# Do this instead:
../../packages/*/node_modules

Best Practices

  • Hoist React Native dependencies: Use hoisting patterns to avoid duplicate native modules
  • Use workspace protocol: Reference local packages with "@my-app/ui": "*" or "workspace:*"
  • Share configs: Keep ESLint, Prettier, and TypeScript configs in shared packages
  • Version together: Use tools like Changesets for coordinated versioning
  • Clear caches frequently: Metro, TypeScript, and bundler caches can cause issues
  • Test in isolation: Ensure packages work independently
  • Document dependencies: Make it clear which packages depend on React Native
  • Use path aliases: Configure TypeScript paths for cleaner imports

Advanced: Turborepo Integration

turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".expo/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false
    }
  }
}
Run with:
npx turbo run build
npx turbo run test