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/.pnpmfolder (the pnpm virtual store). - In individual project folders,
node_modulescontains 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:
- Singleton Pinning - Ensure React, React Native, and Expo packages resolve to single instances to avoid "Invalid hook call" errors.
- Enable Symlinks - Configure Metro to follow symlinks into the pnpm store for transitive dependencies.
-
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' }));
Top comments (0)