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 package

Running 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:

packages/ui/package.json
{
  "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.

apps/mobile/native-components.json
{
  "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.css

Metro 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.

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.

apps/mobile/src/styles/global.css
/* 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:

apps/mobile/metro.config.js
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.

bunfig.toml
[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:

apps/mobile/native-components.json
{
  "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.

On this page