Kirito
The Expo Build That Failed Because of Metro’s Symlink Resolution
Using Yarn workspaces with Expo caused Metro to fail with "Unable to resolve module" – but only on CI, never locally. It took 2 days to find the symlink issue.
The Expo Build That Failed Because of Metro’s Symlink Resolution
"Build failed: Unable to resolve module 'shared-ui'."
The error made no sense. It worked on my machine. It worked on my teammate's machine.
But on our CI server (GitHub Actions), it failed every single time.
The Setup
We had a monorepo with Yarn workspaces:
packages/
mobile/ (Expo app)
shared-ui/ (React Native components)
shared-utils/ (utility functions)
// package.json (root) { "workspaces": ["packages/*"], "private": true }
The Expo app imported from the shared workspace:
// packages/mobile/app/index.js import { Button } from 'shared-ui/components/Button';
shared-ui was symlinked by Yarn into node_modules.
Locally, everything worked. Metro (Expo's bundler) followed the symlinks.
The Failure
On CI, the build failed with:
Error: Unable to resolve module 'shared-ui/components/Button' from 'packages/mobile/app/index.js':
shared-ui could not be found within the project or in these directories:
node_modules
But ls node_modules showed the symlink existed.
Why couldn't Metro find it?
The Root Cause
Metro by default does not follow symlinks outside of the project root.
In CI, the checkout path was different (e.g., /home/runner/work/myapp/myapp vs local /Users/me/projects/myapp).
Metro's symlink resolution is path-dependent. It only follows symlinks that point to directories within the same parent.
Our symlink pointed to ../../packages/shared-ui. Metro saw the .. and refused to follow it because it left the project root.
Locally, the relative path resolved to a directory still under the project root (due to different folder structure). On CI, it didn't.
The Investigation
I enabled Metro's verbose logging:
expo start --verbose 2>&1 | grep -i "symlink"
The logs showed:
[metro] Ignoring symlink ../../packages/shared-ui because it points outside root
That was the smoking gun.
The Fixes
1. Configure Metro to follow symlinks
// metro.config.js const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); // Allow symlinks config.resolver.disableHierarchicalLookup = false; config.watchFolders = [ ...(config.watchFolders || []), // Add the workspace root "/ROOT/web/src/data/../.." ]; // Tell Metro to treat shared packages as source config.resolver.nodeModulesPaths = [ '/ROOT/web/src/data/node_modules', '/ROOT/web/src/data/../../node_modules' ]; module.exports = config;
2. Use Yarn's nohoist (not recommended, but works)
// package.json { "workspaces": { "packages": ["packages/*"], "nohoist": ["**/shared-ui", "**/shared-utils"] } }
This forces Yarn to install copies instead of symlinks.
3. Use a monorepo tool that handles Metro
We switched to Turborepo + expo-yarn-workspaces package:
yarn add -D expo-yarn-workspaces
// package.json { "expo": { "packages": ["packages/mobile"] } }
4. Workaround: Copy packages instead of symlinking in CI
# .github/workflows/build.yml - name: Copy workspace packages (CI workaround) run: | # Remove symlink rm -rf node_modules/shared-ui # Copy actual code cp -r packages/shared-ui node_modules/shared-ui
The Permanent Fix
We wrote a custom Metro configuration that works with any monorepo:
// metro.config.js const path = require('path'); const { getDefaultConfig } = require('expo/metro-config'); const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..'); const config = getDefaultConfig(projectRoot); // 1. Watch all files in the monorepo config.watchFolders = [workspaceRoot]; // 2. Let Metro resolve modules from the workspace root's node_modules config.resolver.nodeModulesPaths = [ path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules'), ]; // 3. Ensure symlinks are followed config.resolver.disableHierarchicalLookup = true; // 4. Ignore the infinite loop of node_modules config.resolver.blacklistRE = /.*/node_modules/.*/node_modules/.*/; module.exports = config;
The Prevention
Test Metro resolution on CI
# Add a script to verify resolution npm run expo export -- --dump-asset
Use absolute imports within monorepo
// jsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "shared-ui/*": ["packages/shared-ui/src/*"], "shared-utils/*": ["packages/shared-utils/src/*"] } } }
Add a pre-flight check
// scripts/check-metro-resolution.js const fs = require('fs'); const path = require('path'); const symlinkTarget = fs.readlinkSync('node_modules/shared-ui'); const absoluteTarget = path.resolve('node_modules/shared-ui', symlinkTarget); if (!absoluteTarget.startsWith(process.cwd())) { console.error('Symlink points outside project root. Metro will fail.'); process.exit(1); }
Commands to Debug Metro Symlinks
Check symlink targets
ls -la node_modules | grep ^l readlink node_modules/shared-ui
Run Metro with debug
export METRO_DEBUG=true expo start --no-dev
Find all resolved paths
npx metro-resolver --platform=ios --entry=index.js
What I Learned
- Metro's symlink behavior is fragile in monorepos.
- CI environments have different path structures than local machines.
- Always test builds on CI before merging.
- The "expo-yarn-workspaces" package exists exactly for this problem.
That 2-day debugging marathon taught me to never assume "works on my machine" means "works anywhere". Now we run all builds in a Docker container that matches CI exactly.