Working Expo + pnpm Workspaces Configuration

Working Expo + pnpm Workspaces Configuration

This post outlines how I got pnpm workspaces to work in a monorepo containing React and React Native projects. I will explain key components in our pnpm workspaces configuration and walk through the errors I encountered trying to get this to work. A complete metro.config.js is attached to the bottom of this blog post.


Prerequisite Knowledge

  • In pnpm workspaces, every module’s packages are stored at a top-level node_modules/.pnpm folder (the pnpm virtual store).
  • In individual project folders, node_modules contains symlinks to this pnpm store.

Overview of our pnpm Workspaces Configuration

We have a shared package in packages/. We import this package using "<package-name>": "*" in package.json.

We use Turbo for running projects and installing dependencies efficiently.


Errors I Encountered and Fixes

Error: Invalid hook call

Fix: Some packages like React and React Native must be pinned to specific versions to avoid multiple instances of them running at the same time. This part of our metro.config.js fixes this (Singleton Pinning):

const singletons = [
  'react',
  'react-native',
  'expo',
  'expo-router',
  'expo-modules-core',
  'expo-constants',
  '@expo/metro-runtime',
];

config.resolver.extraNodeModules = singletons.reduce((acc, name) => {
  acc[name] = path.resolve(projectRoot, 'node_modules', name);
  return acc;
}, {});

Error: Missing transitive dependencies

Fix: Make sure to not use disableHierarchicalLookups: true. This will prevent pnpm from locating packages in the pnpm store. This part of our Metro config solved this problem:

config.watchFolders = [
  path.resolve(monorepoRoot, 'packages'),
  path.resolve(monorepoRoot, 'node_modules/.pnpm')
];

config.resolver.unstable_enableSymlinks = true;
config.resolver.unstable_enablePackageExports = true;

// Resolve from app's node_modules first, then root .pnpm for transitive deps
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules/.pnpm/node_modules'),
];

Error: Missing non-transitive dependency

Fix: Install the packages with npx expo install. This will make sure compatible versions with your Expo version are used.


Conclusion

Getting pnpm workspaces to work with Expo requires careful Metro configuration. The key points are:

  1. Singleton Pinning - Ensure React, React Native, and Expo packages resolve to single instances to avoid “Invalid hook call” errors.
  2. Enable Symlinks - Configure Metro to follow symlinks into the pnpm store for transitive dependencies.
  3. Use npx expo install - Always install Expo-related packages through the Expo CLI to ensure version compatibility.

Complete metro.config.js File

const path = require('path');
const fs = require('fs');
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const { wrapWithReanimatedMetroConfig } = require('react-native-reanimated/metro-config');

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

// Set EXPO_ROUTER_APP_ROOT to absolute path BEFORE config is created
// This ensures require.context resolves correctly with pnpm symlinks
const appRoot = path.resolve(projectRoot, 'app');
process.env.EXPO_ROUTER_APP_ROOT = appRoot;
process.env.EXPO_ROUTER_IMPORT_MODE = 'sync';

const config = getDefaultConfig(projectRoot);

config.projectRoot = projectRoot;

config.watchFolders = [path.resolve(monorepoRoot, 'packages'), path.resolve(monorepoRoot, 'node_modules/.pnpm')];

config.resolver.unstable_enableSymlinks = true;
config.resolver.unstable_enablePackageExports = true;

// Resolve from app's node_modules first, then root .pnpm for transitive deps
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules'),
  path.resolve(monorepoRoot, 'node_modules/.pnpm/node_modules'),
];
// Pin singletons and expo packages to prevent duplicate instances and ensure resolution
const singletons = [
  'react',
  'react-native',
  'expo',
  'expo-router',
  'expo-modules-core',
  'expo-constants',
  '@expo/metro-runtime',
];
config.resolver.extraNodeModules = singletons.reduce((acc, name) => {
  acc[name] = path.resolve(projectRoot, 'node_modules', name);
  return acc;
}, {});

// Add SVG support
config.transformer.babelTransformerPath = require.resolve('react-native-svg-transformer');
config.resolver.assetExts = config.resolver.assetExts.filter(ext => ext !== 'svg');
config.resolver.sourceExts = [...config.resolver.sourceExts, 'svg'];

// Wrap with NativeWind, then Reanimated
module.exports = wrapWithReanimatedMetroConfig(withNativeWind(config, { input: './global.css' }));