Monorepos
A shared package or straight into the app — and the symlink that silently strips your styles.
Components can live in the app that uses them, or in a package several apps share. The CLI decides from where you run it.
cd apps/mobile && bunx delacour init # into the app
cd packages/ui && bunx delacour init # into a shared packageRunning inside a package that is not the app is the answer — there is no prompt and no flag.
From the repository root, init asks.
What init creates
In the shared-package layout it writes a real, importable workspace package — not just a directory with components in it:
{
"name": "@acme/ui",
"type": "module",
"private": true,
"exports": {
"./button": "./src/components/ui/button/index.ts",
"./lib/cn": "./src/lib/cn.ts",
"./styles": "./src/styles/index.css"
},
"peerDependencies": { "react-native-reanimated": "*", "tailwind-variants": "*" }
}Then the app imports it by name, and init adds "@acme/ui": "workspace:*" to the app so the
package manager links it:
import { Button } from "@acme/ui/button";One export per component, and no root barrel
@acme/ui on its own does not resolve, deliberately. A barrel would make every app pull in every
component — and every optional native peer with it. add regenerates the map from what is on disk,
so it cannot drift from the components you actually have.
Native modules are recorded as peer dependencies of the package and installed into the app.
The app owns the versions, because expo install pins them against the SDK, and one copy of each
native module is the difference between working and a crash on the first press.
What changes between the two
native-components.json lives wherever the components live. What moves is app.root, and with it every
file the CLI has to touch but does not own.
{
"paths": { "ui": "src/components/ui", "lib": "src/lib" },
"app": { "root": ".", "css": "src/styles/global.css" }
}apps/mobile/
├── native-components.json
├── metro.config.js
└── src/
├── components/ui/button/
├── lib/
└── styles/global.cssMetro belongs to the app, always
Metro, the Tailwind entry and the native dependencies are the app's, whatever package the
components sit in. init finds the Expo app — up from where you ran it, then across apps/ — and
wires that one. expo install runs there too: two copies of a native module register twice and
break at runtime.
The symlink that costs you every style
This is the failure worth reading twice, because nothing reports it and it only shows up in a release build.
Tailwind compiles the classes it finds by scanning source text. Your package manager links a
workspace package into node_modules, and Tailwind's scanner does not follow symlinks. A
@source pointing at the linked copy therefore contributes nothing at all: no error, no warning,
and every class your components use is dropped from the build.
/* Silently scans nothing. */
@source "../../node_modules/@acme/ui/src";
/* The real directory. This is what init writes. */
@source "../../../../packages/ui/src/components/ui";init computes those globs from where the files actually landed on disk, so the real path is what
comes out. delacour doctor re-checks the coverage afterwards, resolving symlinks as it goes.
One copy of each native module
A monorepo invites a second copy: the package manager can materialise react-native-reanimated
under the app while the workspace root holds another. Two registrations of a native module break
at runtime.
init writes this into the app's metro.config.js for you in the shared-package layout, and
delacour doctor checks it:
config.resolver.extraNodeModules = {
"react-native": path.resolve(workspaceRoot, "node_modules/react-native"),
"react-native-reanimated": path.resolve(workspaceRoot, "node_modules/react-native-reanimated"),
"react-native-gesture-handler": path.resolve(workspaceRoot, "node_modules/react-native-gesture-handler"),
};Keep withUniwindConfig the outermost wrapper around whatever else you add — init places the
resolver block before it, and doctor checks the ordering.
Bun needs a flat install
Add a bunfig.toml at the workspace root with linker = "hoisted". Bun's default isolated layout
hides packages under node_modules/.bun/…, which Metro cannot follow and which makes TypeScript
resolve the package's copy of React Native to a different realpath than the app's — every ref in
the library then fails to typecheck, naming neither cause.
[install]
linker = "hoisted"Several apps, one package
Point each app's Tailwind entry at the package and give each its own Metro wrapper. native-components.json
names one app in app.root — the one init and doctor work against — so run doctor from each
app's directory to check the others:
bunx delacour doctor --cwd apps/mobile
bunx delacour doctor --cwd apps/tablet--cwd apps/tablet finds the nearest native-components.json walking upwards. If your second app has no
config of its own, it resolves the shared package's — which names the first app in app.root.
Give each app a native-components.json when they diverge.
Sharing without a package
You do not need a workspace package at all. Point two apps at the same directory with paths
containing ../, and both get the same files with no publishing step:
{
"paths": { "ui": "../../shared/components/ui", "lib": "../../shared/lib" },
"app": { "root": "." }
}aliases will be empty unless a tsconfig mapping covers that directory, so the imports come out
relative — which resolves without experiments.tsconfigPaths.