Mobile — React Native Development

React Native Monorepo Setup with Turborepo

Direct answer

A React Native monorepo with Turborepo puts the mobile app, web app, and shared packages — design tokens, API client, domain types — in one repository with npm or pnpm workspaces, uses turbo.json to orchestrate and cache builds, lint, and typecheck across packages, and configures Metro to watch the workspace root and resolve hoisted dependencies. The critical rules: exactly one React and one react-native version across the workspace, and native dependencies declared in the app package itself.

Once a product has a React Native app and a React web app maintained by the same small team, separate repositories mean duplicated types, drifting API clients, and cross-repo PR dances. This is the Turborepo setup I use to make one change land everywhere at once — and the Metro traps that catch nearly everyone the first time.

Key facts, with sources

  • React Native 0.76, released October 23, 2024, enabled the New Architecture by default and shipped with over 1,070 commits from 156 contributors plus a roughly 15x faster Metro resolver. (React Native official blog)
  • The State of React Native 2024 survey collected 3,501 responses, up from about 2,400 the previous year, covering more than 15 areas of the ecosystem. (State of React Native survey)
  • About 20% of State of React Native 2024 respondents reported apps with more than 100,000 users, up from 14% the year before. (InfoQ)
  • Shopify migrated all of its mobile apps to React Native over five years and reports sub-500ms (P75) screen loads and over 99.9% crash-free sessions in production. (Shopify Engineering)
  • 88% of surveyed React Native developers feel the framework is progressing positively, while better debugging remains the top request, cited by 54% of respondents. (SSOJet (State of React Native 2024 highlights))

When a monorepo actually pays off

The monorepo earns its complexity when you have genuinely shared code and one team touching all of it: a mobile app and web dashboard hitting the same API, a design system expressed on both platforms, validation and business logic that must never drift between clients. In that world, a backend field rename becomes a single PR that updates the schema package and every consumer, checked by one CI run — versus two repos, two PRs, and a window where they disagree.

It does not pay off for a mobile-only product with no web sibling, or for separate teams that want independent release cadences and ownership boundaries — there, repo separation is the feature. My rule of thumb: if you are about to copy a types file or an API client between two repositories for the second time, stop and set up the monorepo instead.

Workspace layout that stays sane

The layout I use: an apps directory containing mobile (the React Native or Expo app) and web (Next.js), and a packages directory containing ui (shared design tokens and primitives), api-client (fetch layer plus zod schemas plus inferred types), core (domain logic and utilities), and config (shared eslint, TypeScript, and jest presets). Each package has its own package.json with an explicit name that apps import by — no reaching into sibling directories with relative paths.

Two conventions prevent later pain. First, shared packages export plain TypeScript compiled by each consumer's bundler — Metro and Next both handle transpiling workspace source directly, which avoids a build-watch dance during development. Second, anything platform-specific stays out of shared packages or behind platform-suffixed files; the moment packages/core imports react-native, your web build breaks and the boundary was fiction.

Configuring the Turborepo task graph

Turborepo's job is running tasks across the workspace in dependency order and caching everything deterministic. The configuration lives in turbo.json: build depends on the builds of upstream packages, typecheck and test depend on whatever they consume, and lint runs everywhere independently. Once configured, turbo run typecheck at the repo root checks the entire product, and — the part that transforms CI — caches results so packages untouched by a PR are skipped entirely.

With remote caching enabled, your CI and teammates share that cache, so a PR touching only the web app never re-runs mobile checks. In practice this turns monorepo CI from the thing everyone fears into the fastest CI most teams have had.

turbo.json task graph
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "lint": {},
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

Making Metro understand the workspace

Metro is the step where most React Native monorepo attempts stall, because Metro by default watches only the app's own directory and resolves modules from its own node_modules. In a workspace, shared packages live outside the app folder and dependencies may be hoisted to the root node_modules, so an unconfigured Metro simply cannot find them.

The fix is three settings: watch the workspace root so edits to shared packages trigger reloads, add both the app's and the root's node_modules to the resolver paths, and — with Expo — start from expo/metro-config which handles much of this in recent SDKs. After changing Metro config, restart with a cleared cache; stale Metro caches cause more phantom monorepo bugs than any other single source.

metro.config.js for a workspace app
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);

// Watch shared packages so edits hot-reload the app
config.watchFolders = [workspaceRoot];

// Resolve modules from the app first, then the hoisted root
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];

module.exports = config;

The pitfalls: duplicate React, hoisting, native modules

Three traps account for most monorepo misery. First, duplicate React: if the resolver finds two copies of react or react-native — one hoisted, one nested — you get the infamous invalid hook call errors that look like your bug but are not. Enforce a single version across every workspace package, and pin resolutions if a stray dependency drags in another copy. Second, hoisting surprises: some native libraries assume they live in the app's own node_modules; pnpm's stricter linking or targeted no-hoist rules tame this.

Third — the rule I state in every setup — native dependencies belong in the app package's own package.json, never only in a shared package. Autolinking scans the app's dependencies to generate the native build; a camera library declared only in packages/ui will typecheck perfectly and then fail at runtime with a missing native module. Shared packages may depend on native libraries as peer dependencies, but the app declares the real one.

CI and release flow for the workspace

The monorepo's CI story is where the setup pays rent daily. Every PR runs turbo run lint typecheck test at the root; Turborepo's cache and affected-package detection mean a docs change finishes in seconds while a shared-package change correctly fans out to every consumer. Mobile builds stay separate from PR checks — EAS Build or your native pipeline triggers on merges or tags, building from apps/mobile with the workspace intact, which EAS supports well.

Versioning stays simple if you let it: internal packages do not need semver ceremonies or publishing — apps consume workspace source directly, and the repo's git history is the version. Reserve real versioning for packages you publish externally. The one discipline to keep forever: shared-package PRs must run consumers' checks, never just their own — the entire point of the monorepo is that the api-client package cannot 'pass' while breaking the app importing it.

When to hire senior help

Bring in senior React Native help when facing a New Architecture or major version migration, persistent performance regressions, or a first store launch, since these are the phases where inexperienced teams lose the most months. A short senior architecture audit early in the project is consistently cheaper than a rescue or rewrite later. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.

Bottom line

Dhairya Senjaliya ships Mobile — React Native Development projects worldwide — book a scoping call to discuss your specific situation.

Common pitfalls to avoid

  • Staying multiple versions behind on React Native, then facing a compound upgrade to the New Architecture because popular libraries have dropped old-architecture support
  • Treating React Native as write-once-run-anywhere and only QA-testing on iOS, shipping Android builds with broken back-button handling, keyboard behavior, and gesture bugs
  • Pulling in unmaintained third-party native modules without checking TurboModule and Fabric compatibility, which later blocks the New Architecture migration
  • Launching without crash and performance monitoring wired in, so the team only discovers jank and crash clusters from one-star reviews instead of telemetry

Frequently asked questions

Why does Metro fail to resolve packages in a React Native monorepo?

By default Metro only watches the app's own folder and resolves from its own node_modules, but workspaces put shared packages outside the app and hoist dependencies to the repo root. Fix it by setting watchFolders to the workspace root and adding both the app's and root's node_modules to resolver.nodeModulesPaths, then restart Metro with a cleared cache — stale caches cause most phantom resolution errors.

How do you avoid duplicate React versions in a Turborepo monorepo?

Ensure exactly one version of react and react-native is declared consistently across all workspace packages — shared packages should list them as peer dependencies, and only the apps declare real versions. If a third-party dependency drags in a second copy, pin it with your package manager's resolutions or overrides. Duplicate copies cause invalid-hook-call errors that look like application bugs but are purely a resolution problem.

Where should native modules be declared in a React Native monorepo?

Always in the app package's own package.json, because autolinking scans the app's direct dependencies to generate the native iOS and Android builds. A native library declared only inside a shared package will pass typechecking and then crash at runtime with a missing native module. Shared packages that need a native library should declare it as a peer dependency, with the app providing the real installation.

Is React Native still a good technology bet in 2026?

Yes for teams with JavaScript or React skills; the New Architecture has been the default since version 0.76 in late 2024, and the framework is used in production by Meta, Microsoft, Shopify, and Amazon. In the latest State of React Native survey, 88% of developers said the framework is heading in a positive direction.

Can a React Native app feel as fast as a fully native app?

For most business, e-commerce, and content apps, yes; Shopify runs its entire app portfolio on React Native with sub-500ms P75 screen loads and over 99.9% crash-free sessions. Workloads like heavy 3D, AR, or real-time audio processing still warrant native modules or fully native builds.

How much code is actually shared between iOS and Android?

Production teams commonly report 85 to 95%+ shared code; published examples include Instagram at 85 to 99% and Shopify at roughly 86%. The remainder is platform-specific work such as payments, widgets, and deep OS integrations.

Bottom line: Dhairya Senjaliya ships Mobile — React Native Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.

Sources

Related guides

Keep up with new guides

New deep-dive guides on React Native, Python, and AI ship regularly. Subscribe via RSS or follow on LinkedIn.

Want help implementing this?

30-minute scoping call · Clear milestones · Senior engineer ownership