vibekōdo
Back to blog

Developing an in-repo library with React Native 0.76 & Metro 0.81

vibekōdo3 min read

TL;DR Add the library’s folder to watchFolders, give Metro a catch-all extraNodeModules proxy, delete the deprecated unstable_enableSymlinks flag, and you can live-edit TypeScript inside libs/… with Fast Refresh.


Context

  • App: frontend/app (React Native 0.76.9)
  • Local library: libs/ts/samplelib (published in package.json as
    "samplelib": "file:../../libs/ts/samplelib")
  • Metro: 0.81 — symlink support is always on

Goal: edit samplelib in place, see changes immediately in the running app, no extra build step, no duplicate dependencies.


First attempt — native symlink

Pointing the dependency at the folder:

// frontend/app/package.json
{
  "dependencies": {
    …
    "samplelib": "file:../../libs/ts/samplelib"
  }
}

Result

Metro threw:

Unable to resolve module @babel/runtime/helpers/interopRequireDefault
from libs/ts/samplelib/src/singleAtomWatcher.ts

It was able to read the file but choked on the helper import.


Why the error happens

  1. Metro follows Node-style lookup starting at the file’s own directory: libs/ts/samplelib/src.
  2. It searches upward for node_modules.
  3. Because the library is outside the app, the upward walk never reaches frontend/app/node_modules where @babel/runtime lives.

Step 1 — let Metro see the library

watchFolders tells Metro where to watch for source changes.

// metro.config.js
config.watchFolders = [path.resolve(__dirname, "../../libs/ts/samplelib")];

Now Metro can load the TS files, but dependency resolution still fails.


Step 2 — global fallback for dependencies

extraNodeModules works like Webpack’s alias.
A tiny proxy makes any unresolved package fall back to the app’s own node_modules:

config.resolver.extraNodeModules = new Proxy(
  {},
  {
    get: (_, name) => path.join(__dirname, "node_modules", name),
  }
);

After adding that (and a quick --reset-cache) Metro found @babel/runtime and everything compiled.


Bonus — point Metro at the source instead of dist

The library’s package.json originally said:

{ "main": "dist/index.js" }

Changing it to:

{
  "react-native": "src/index.ts",
  "main": "dist/index.js"
}

lets Metro compile TypeScript on the fly; no separate npm run build needed during development.
Other tools (Node, Webpack, Vite) still consume the compiled dist build.


The final metro.config.js

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

const config = getDefaultConfig(__dirname);

// 1. custom extensions
config.resolver.sourceExts.push("sql");

// 2. watch the local library
config.watchFolders = [path.resolve(__dirname, "../../libs/ts/samplelib")];

// 3. global fallback for packages
config.resolver.extraNodeModules = new Proxy(
  {},
  { get: (_, name) => path.join(__dirname, "node_modules", name) }
);

module.exports = config;

(Notice there’s no unstable_enableSymlinks flag — Metro 0.81 made symlink support permanent.)

The final package.json

You may want to add a postinstall script to install the library's dependencies, for the tsc compilation to work.

 "scripts": {
    // …existing scripts…
    "postinstall": "npm --prefix ../../libs/ts/samplelib install"
  }

Takeaways

  1. Symlinks are first-class in modern Metro; you rarely need hacks.
  2. watchFolders exposes code outside projectRoot.
  3. extraNodeModules bridges dependencies outside projectRoot.
  4. Use the react-native field to direct Metro to TypeScript sources, avoiding a manual build loop.

Happy monorepo hacking!