Developing an in-repo library with React Native 0.76 & Metro 0.81
TL;DR Add the library’s folder to
watchFolders
, give Metro a catch-allextraNodeModules
proxy, delete the deprecatedunstable_enableSymlinks
flag, and you can live-edit TypeScript insidelibs/…
with Fast Refresh.
Context
- App:
frontend/app
(React Native 0.76.9) - Local library:
libs/ts/samplelib
(published inpackage.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
- Metro follows Node-style lookup starting at the file’s own directory:
libs/ts/samplelib/src
. - It searches upward for
node_modules
. - 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
- Symlinks are first-class in modern Metro; you rarely need hacks.
- watchFolders exposes code outside
projectRoot
. - extraNodeModules bridges dependencies outside
projectRoot
. - Use the
react-native
field to direct Metro to TypeScript sources, avoiding a manual build loop.
Happy monorepo hacking!