DEV Community

Rad Code
Rad Code

Posted on

Surviving pnpm + React Native: How I Finally Stopped Metro from Screaming About `@babel/runtime`

Do-Not-Stop is a continuously evolving Web3 frontend playground — built with Vite, React, TypeScript, viem, and wagmi.

It’s designed to grow, adapt, and experiment with the latest in Ethereum, Solana, and React Native development — a living project that explores how a single modern codebase can span web and mobile in the Web3 space.

Naturally, I used pnpm workspaces. They’re fast, clean, and perfect for sharing code between multiple projects.

Until Metro — React Native’s bundler — decided it hated my setup.


🏗 The Setup

do-not-stop/
├─ frontend/              ← web (Vite + React + wagmi)
├─ mobile/                ← React Native
├─ packages/shared-auth/  ← shared logic between web & mobile
└─ pnpm-workspace.yaml
Enter fullscreen mode Exit fullscreen mode

The goal was simple: import packages/shared-auth into both web and mobile.

Everything looked good — pnpm install was blazing fast, dependencies deduped — until Metro threw this:

Error: Unable to resolve module @babel/runtime/helpers/interopRequireDefault
Enter fullscreen mode Exit fullscreen mode

Even though mobile/node_modules/@babel/runtime clearly existed.


🧩 Where Everything Fell Apart

Here’s the thing about pnpm: it doesn’t install packages “flat” like npm or yarn.

Instead, it builds a virtual store under .pnpm/ and links everything through symlinks.

Example:

mobile/node_modules/@babel/runtime
  → ../../node_modules/.pnpm/@babel+runtime@7.x/node_modules/@babel/runtime
Enter fullscreen mode Exit fullscreen mode

Node can follow that fine, but Metro’s resolver often doesn’t.

It just climbs directories until it finds a node_modules, so it ends up loading stuff from the workspace root, not from the mobile app’s local copy.


🧠 I Tried Everything First

When you start googling “pnpm react native @babel/runtime,” you end up in GitHub issues and Stack Overflow threads full of half-solutions.

I tried disabling hoisting:

hoistPattern: []
Enter fullscreen mode Exit fullscreen mode

Then isolating linkers:

node-linker=isolated
Enter fullscreen mode Exit fullscreen mode

Then the nuclear option:

shamefully-hoist=true
Enter fullscreen mode Exit fullscreen mode

Each time, something else broke — either the mobile build, or the shared packages stopped linking, or Metro just refused to cooperate.

Nothing really fixed it.


⚙️ The Real Fix — Teach Metro to Understand pnpm

The problem wasn’t pnpm.

The problem was Metro’s assumptions.

It expects a flat node_modules world, but pnpm builds a smart, symlinked tree.

So instead of forcing pnpm to behave like npm, I made Metro understand pnpm.

Here’s the final working metro.config.js inside my mobile/ folder:

const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

/**
 * Metro configuration for pnpm monorepos
 * (do-not-stop project)
 *
 * - Lets Metro follow pnpm's symlinks
 * - Always prefers the mobile app's own node_modules
 * - Watches the monorepo root so shared packages rebuild correctly
 */
const defaultConfig = getDefaultConfig(__dirname);

const config = {
  watchFolders: [path.resolve(__dirname, '..')],
  resolver: {
    ...defaultConfig.resolver,
    unstable_enableSymlinks: true,
    unstable_enablePackageExports: true,
    extraNodeModules: new Proxy(
      {},
      {
        get: (_target, name) =>
          path.join(__dirname, 'node_modules', String(name)),
      }
    ),
  },
};

module.exports = mergeConfig(defaultConfig, config);
Enter fullscreen mode Exit fullscreen mode

🧪 Bonus: Using Shared Packages

Because watchFolders includes the repo root, Metro can detect changes in packages/shared-auth/ (or any shared package).

If you want finer control:

watchFolders: [
  path.resolve(__dirname, '..', 'packages', 'shared-auth'),
  path.resolve(__dirname, '..', 'packages', 'utils'),
],
Enter fullscreen mode Exit fullscreen mode

🧠 Takeaways

  • pnpm’s layout isn’t broken — it’s just smarter than Metro expects.
  • Disabling hoisting or flattening installs only hides the issue.
  • The real solution lives in Metro’s resolver config, not your package manager.
  • When mixing web + mobile in a single repo, configure your tools to coexist — not compete.

🎉 Conclusion

This setup now powers do-not-stop:

  • A React web app (frontend/)
  • A React Native app (mobile/)
  • Shared code in packages/shared-auth/
  • All managed by pnpm

Do-Not-Stop isn’t just a playground anymore — it’s an evolving multi-platform Web3 stack that grows alongside the latest in Ethereum, Solana, and modern React development.

After a week of battling hoisting, symlinks, and Babel errors, I stopped fighting pnpm and just made Metro smarter.

If you’re running React Native inside a pnpm monorepo, drop this config in your app and get back to building 🚀


Top comments (0)