Expo• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

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.