Building a Universal React Monorepo with Next.js and Expo

Universal React Monorepo running on three platforms: web browser, iOS simulator, and Android emulator, all displaying the same shared UI components

This guide walks through setting up a monorepo that runs both Next.js (web) and Expo (mobile), sharing UI components across platforms. The shared UI uses NativeWind, while the Next.js app uses Tailwind CSS for styling. We’ll use Turborepo for task orchestration and pnpm workspaces for package management.

💡 Want to skip the setup?

You can clone the complete template repository: universal-react-monorepo and get started immediately.
Don't forget to ⭐ star it if you find it useful!

Prerequisites

Required:

  • Node.js 18+
  • pnpm 10+ Optional:
  • Turbo CLI (for running turbo commands globally)
  • GitHub CLI (to automate repo creation) Mobile Development:
  • iOS Simulator (macOS only)
  • Android Studio

Initialise the repository

Create a new project and set up git:

# 1. Create and enter project folder
mkdir mammoth && cd mammoth

# 2. Initialize git
git init

# 3. Create README
echo "# mammoth" > README.md

# 4. Stage and commit README
git add README.md && git commit -m "Initial commit"

If you use Github CLI:

gh repo create mammoth --public --source=. --remote=origin --push

Initialise the workspace with pnpm:

pnpm init

This generates a package.json. For now it only defines the metadata and the package manager version.

// package.json (repo root)
{
  "name": "mammoth",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.15.0"
}

Define the workspace

Create a pnpm-workspace.yaml file, which tells pnpm which folders contain apps and packages:

packages:
- "apps/*"
- "packages/*"

Create the folders:

mkdir -p apps packages

Add a .gitignore:

node_modules

Add Turborepo

Install Turborepo as a dev dependency in the workspace root:

# terminal (repo root)
pnpm add turbo --save-dev --workspace-root

This updates package.json:

// package.json (repo root)
{
	"devDependencies": {
	"turbo": "^2.5.6"
	}
}

It also creates a pnpm-lock.yaml, which locks exact dependency versions for reproducible installs.

Next, create turbo.json at the root to define tasks:

// turbo.json (repo root)
{
	"$schema": "https://turborepo.com/schema.json",
	"tasks": {
		"build": {
			"dependsOn": ["^build"],
			"outputs": [".next/**", "!.next/cache/**"]
		},
	"check-types": {
		"dependsOn": ["^check-types"]
		},
	"dev": {
			"persistent": true,
			"cache": false
		}
	}
}

Update .gitignore to include:

.turbo

This directory will hold cached task outputs.

Note: For a deeper understanding, read through the Turborepo documentation. It’s also useful to install the Turbo CLI globally so you can run turbo commands directly from your terminal.

Add the Next.js app

Create the web app inside apps/. Use pnpm to avoid package-lock.json.

# terminal (repo root)
cd apps
npx create-next-app@latest web --use-pnpm

When prompted, select the following options for optimal monorepo setup: Next.js setup options showing TypeScript enabled, ESLint enabled, Tailwind CSS disabled, src/ directory enabled, App Router enabled, and import alias customisation disabled

If you accidentally ran it without --use-pnpm, delete apps/web/package-lock.json. The monorepo uses pnpm and a single pnpm-lock.yaml at the root for reproducible installs.

Wire scripts to Turborepo

Add root scripts so Turborepo can orchestrate tasks across all apps/packages.

// package.json (repo root)
{
	"scripts": {
		"dev": "turbo dev",
		"build": "turbo build",
		"lint": "turbo lint",
		"check-types": "turbo check-types"
	}
}

Expose check-types in the web app so the root task can propagate.

// package.json (apps/web)
{
	"scripts": {
		"dev": "next dev --turbopack",
		"build": "next build --turbopack",
		"start": "next start",
		"lint": "eslint",
		"check-types": "tsc --noEmit"
	},
}

Install after editing scripts:

# terminal (apps/web)
pnpm install

Note: Any time you change package.json scripts or deps, run pnpm install to sync the lockfile and workspace.

Native builds approval (pnpm)

You might encounter the following approve-builds warning: Terminal showing pnpm warning about native dependencies requiring approval before building This is pnpm's safeguard for building native binaries.

To address this, you can run:

# terminal (apps/web)
pnpm approve-builds
# Select: sharp, unrs-resolver → confirm

This records sharp and unrs-resolver under onlyBuiltDependencies and avoids future prompts on CI:

# pnpm-workspace.yaml (repo root)
packages:
	- apps/*
	- packages/*

onlyBuiltDependencies:
	- sharp
	- unrs-resolver

First build and caching

If this is your first time using Turborepo then the following will give you a taste of why we are using this in our project:

Run a full build:

# terminal (repo root)
turbo build

You can see that the first time we run this command, the cache misses: Terminal output showing Turborepo cache miss with 'MISS' status for the web:build task

The initial build took 6 seconds: Terminal showing build completion in 6.12s with task summary

Now let's run it again:

# terminal (repo root)
turbo build

This time around notice the cache hit: Terminal output showing Turborepo cache hit with 'HIT' status for the web:build task

The cached build is nearly instant, taking only 83ms: Terminal showing cached build completion in just 83ms

Turborepo computes a hash of inputs and replays prior outputs/logs on cache hits. This applies to any task (buildcheck-types, etc.). Simply put since we haven't changed anything in the code, there is no need to rebuild the app from beginning and as a consequence we saved ourselves a whoping 6 seconds. You can later enable Remote Caching to share build artifacts across machines.

Note: There’s an excellent walkthrough by Michael Guay on YouTube covering Turborepo with pnpm workspaces.

Lint task sync

Up to this point, I deliberately left out a change to illustrate a common issue. You may have noticed that while the Next.js app defines lint script, and we added lint at the root level, the task is missing from turbo.json.

Turborepo only orchestrates tasks that are explicitly declared in turbo.json. If a task exists in a package but not in turbo.json, running it via Turbo will fail. Terminal error showing 'turbo lint' command failed because lint task is not defined in turbo.json The fix is to keep scripts in sync: every script in an app or package that should run across the monorepo must also be declared in turbo.json.

Add the lint task:

// turbo.json (repo root)
{
	"$schema": "https://turborepo.com/schema.json",
	"tasks": {
		"build": {
			"dependsOn": ["^build"],
		},
		"check-types": {
			"dependsOn": ["^check-types"]
		},
		"lint": {
			"dependsOn": ["^lint"]
		},
		"dev": {
			"persistent": true,
			"cache": false
		}
	}
}

now running turbo lint works as expected.

Add the expo app

Inside the /apps folder, create the mobile app:

# terminal (repo root)
cd apps
npx create-expo-app@latest mobile

By default, Expo uses npm. Clean up and switch to pnpm:

# terminal (apps/)
cd mobile
rm package-lock.json
pnpm install

Recall that removing package-lock.json and re-installing with pnpm ensures the app relies on the root pnpm-lock.yaml instead of keeping a separate lockfile.

Reset the project configuration:

# terminal (apps/mobile)
pnpm run reset-project

Update package.json:

// package.json (apps/mobile)
{
	"scripts": {
		"dev": "expo start",
		"android": "expo start --android",
		"ios": "expo start --ios",
		"web": "expo start --web",
		"lint": "expo lint",
		"check-types": "tsc --noEmit"
	},
}

Then run:

# terminal (apps/mobile)
pnpm install

and running turbo dev should start the mobile dev env.

Use the Turborepo TUI

When you run turbo dev from repo root, logs from web and mobile overlap. Terminal showing overlapping log output from both web and mobile development servers running simultaneously

Enabling the TUI helps you manage each task in its own pane.

Add to turbo.json:

// turbo.json (repo root)
{
	"$schema": "https://turborepo.com/schema.json",
	"ui": "tui",
}

Start dev:

# terminal (repo root)
turbo dev

The TUI separates logs by task and lets you focus on web or mobile without losing the other process.

The mobile logs look as follows: Turborepo TUI showing mobile development server logs with Expo QR code and Metro bundler information

By pressing the down arrow you can switch to the web logs: Turborepo TUI showing Next.js web development server logs with local and network URLs

Install Nativewind in Expo

NativeWind enables Tailwind CSS styling in React Native applications. Follow the official installation guide for the most up-to-date instructions.

Install the required packages:

# terminal (repo root)
cd apps/mobile
pnpm install nativewind react-native-reanimated@~3.17.4 react-native-safe-area-context@5.4.0
pnpm install -D tailwindcss@^3.4.17 prettier-plugin-tailwindcss@^0.5.11

Init Tailwind:

# terminal (apps/mobile)
pnpm tailwindcss init

If you moved your Expo app folder into src, use this Tailwind config:

// tailwind.config.js (apps/mobile)
/** @type {import('tailwindcss').Config} */
module.exports = {
  // NOTE: Update this to include the paths to all files that contain Nativewind classes.
  content: [["./src/**/*.{js,jsx,ts,tsx}"]],
  presets: [require("nativewind/preset")],
  theme: {
    extend: {},
  },
  plugins: [],
}

Create global styles:

/* global.css (apps/mobile) */
@tailwind base;
@tailwind components;
@tailwind utilities;

Configure Babel:

// babel.config.js (apps/mobile)
module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};

Configure Metro:

// metro.config.js (apps/mobile)
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require('nativewind/metro');
 
const config = getDefaultConfig(__dirname)
 
module.exports = withNativeWind(config, { input: './global.css' })

Type support:

// nativewind-env.d.ts (apps/mobile)
/// <reference types="nativewind/types" />

Ensure TS picks it up

// tsconfig.json (apps/mobile)
{
  "include": [
	"**/*.ts",
	"**/*.tsx",
	".expo/types/**/*.ts",
	"expo-env.d.ts",
	"nativewind-env.d.ts"
	]
}

Import CSS in your root layout (Expo router):

// _layout.tsx (apps/mobile/src/app)
import { Stack } from "expo-router";
import "../../global.css";
  
export default function RootLayout() {
	return <Stack />;
}

Edit index.tsx :

// index.tsx (apps/mobile/src/app)
import { Text, View } from "react-native";

export default function Index() {
	return (
		<View className="flex-1 items-center justify-center bg-white">
			<Text className="text-4xl font-bold">Hello World</Text>
		</View>
	);
}

First run and common pnpm fix

Start dev:

# terminal (apps/mobile)
turbo dev

And chances are you will hit this Metro resolution error: Terminal showing Metro bundling error about unable to resolve module dependencies in the monorepo

If you want to move past this issue, apply this cleanup:

# terminal (repo root)
rm -rf node_modules apps/**/node_modules
printf "node-linker=hoisted\n" > .npmrc
watchman watch-del-all || true
pnpm install
pnpm dev --reset-cache

(credit to https://github.com/nativewind/nativewind/issues/701 ) GitHub issue thread showing the community solution for Metro bundling problems in pnpm monorepos

Why: Metro expects a flat node_modulesnode-linker=hoisted reduces symlink complexity so Metro resolves packages cleanly. Resetting Watchman and Metro cache removes stale paths.

Then this issue should be fixed, and running

# terminal (apps/mobile)
turbo dev

works flawlessly: iOS Simulator displaying 'Hello World' text in large font on a white background

Align React versions across the monorepo

Pin React and RN variants once at the root to avoid peer conflicts:

// package.json (repo root)
{
  "overrides": {
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "react-native": "0.79.5",
    "react-native-web": "0.20.0",
    "tailwindcss": "3.4.17"
  }
}

Ensure apps/web/packages.json uses the same react and react-dom versions, then reinstall:

# terminal (repo)
pnpm install

Why: A single React tree across apps prevents duplicate React copies and Metro/Webpack conflicts.

Install Tailwind CSS v3 in Next.js

Follow the Next.js Tailwind v3 guide.

Install dependencies:

# terminal (apps/web)
pnpm add -D tailwindcss@^3 postcss autoprefixer
npx tailwindcss init -p

This creates a tailwind.config.js and postcss.config.js.

Configure Tailwind:

// tailwind.config.js (apps/web)
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
}

Configure PostCSS:

// postcss.config.js (apps/web)
module.exports = {
	plugins: {
		tailwindcss: {},
		autoprefixer: {},
	},
}

Global styles:

/* global.css (apps/web/src/app) */
@tailwind base;
@tailwind components;
@tailwind utilities;

Remove page.module.css. Update page.tsx:

// page.tsx (apps/web/src/app)
export default function Home() {
	return (
	<div className="flex flex-col items-center justify-center h-screen">
		<h1 className="text-4xl font-bold">Hello World</h1>
	</div>
	);
}

Result in browser: Browser showing Next.js development server with 'Hello World' heading centred on the page

Now install react-native-web to integrate RN components:

# terminal (apps/web)
pnpm add react-native-web

Update Next.js config:

// next.config.ts (apps/web)
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
	transpilePackages: [
	'react-native',
	'react-native-web',
	],
	webpack: (config) => {
		config.resolve.alias = {
		...(config.resolve.alias || {}),
		'react-native$': 'react-native-web',
	};
		config.resolve.extensions = [
		'.web.ts',
		'.web.tsx',
		'.web.js',
		...config.resolve.extensions,
	];
	return config;
	}
}

export default nextConfig

Update package.json scripts to disable Turbopack:

// package.json (apps/web) 
"dev": "next dev",
"build": "next build"

Re-install:

# terminal (apps/web)
pnpm install

Test with React Native components in Next.js:

// page.tsx (apps/web/src/app)
import { View, Text } from "react-native";

export default function Home() {
	return (
		<div className="flex flex-col items-center justify-center h-screen">
			<h1 className="text-4xl font-bold">Hello World</h1>
			<View className="flex-1 items-center justify-center bg-white">
				<Text>Hello from React Native</Text>
			</View>
		</div>
	);
}

Result: Browser displaying both HTML heading and React Native Text component rendered via react-native-web

Configure NativeWind for Next.js

Update next.config.js:

// next.config.js (apps/web)
transpilePackages: [
	'react-native',
	'react-native-web',
	'nativewind',
	'react-native-css-interop'
],

Update tailwind.config.js to add NativeWind preset :

/* tailwind.config.js (apps/web) */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
}

Update tsconfig.json add to enable type support:

// tsconfig.json (apps/web)
{
  "compilerOptions": {
    "jsxImportSource": "nativewind",
    "types": ["nativewind/types"]
  }
}

Demo: NativeWind in Next.js

To verify the NativeWind setup, add a demo page:

// apps/web/src/app/nativewind/page.tsx
"use client";

import { View, Text } from "react-native";

export default function NativewindDemo() {
	return (
		<View className="flex-1 items-center justify-center bg-white h-screen">
			<Text className="text-lg font-bold text-black">
			Hello from NativeWind + React Native!
			</Text>
			<View className="mt-4 p-4 bg-gray-100 rounded">
				<Text className="text-black">This is a NativeWind demo.</Text>
			</View>
			<View className="mt-4 px-6 py-3 bg-blue-500 rounded-lg shadow-lg">
				<Text className="text-white text-base font-semibold">
				Styled with NativeWind!
				</Text>
			</View>
		</View>
	);
}

Result: Browser showing NativeWind demo with styled text and buttons using Tailwind classes on React Native components

Shared UI package Setup

Now we'll create a packages/ui package with reusable React Native components styled by NativeWind. Components run on both web (react-native-web) and mobile without changes

Structure

packages/ui/
├── package.json           # Package configuration
├── tsconfig.json          # TypeScript configuration
├── eslint.config.js       # ESLint configuration
├── nativewind-env.d.ts    # NativeWind type declarations
├── tailwind.config.js     # Tailwind CSS configuration
└── src/
    ├── index.ts           # Main export file
    └── components/
        ├── Button.tsx
        ├── Text.tsx
        └── View.tsx

Setup

Create folder:

# terminal (repo root)
cd packages && mkdir ui && cd ui && mkdir src

package.json:

// packages/ui/package.json
{
  "name": "@mammoth/ui",
  "version": "1.0.0",
  "private": true,
  "main": "src/index.ts",
  "types": "src/index.ts",
  "scripts": {
    "lint": "eslint .",
    "check-types": "tsc --noEmit"
  },
  "dependencies": {
    "nativewind": "^4.1.23"
  },
  "devDependencies": {
    "@types/react": "~19.0.10",
    "eslint": "^9.25.0",
    "eslint-config-expo": "~9.2.0",
    "tailwindcss": "^3.4.17",
    "typescript": "~5.8.3"
  },
  "peerDependencies": {
    "react": "19.0.0",
    "react-native": "0.79.5"
  }
}

tsconfig.json:

// packages/ui/tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "nativewind-env.d.ts"
  ],
  "exclude": ["node_modules"]
}

eslint.config.js:

// packages/ui/eslint.config.js
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');

module.exports = defineConfig([
  expoConfig,
  {
    ignores: ['dist/*', 'node_modules/*'],
  },
]);

nativewind-env.d.ts

// packages/ui/nativewind-env.d.ts
/// <reference types="nativewind/types" />

tailwind.config.js

// packages/ui/tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  presets: [require("nativewind/preset")],
  theme: { extend: {} },
  plugins: [],
}

Components

Button.tsx

// packages/ui/src/components/Button.tsx
import { TouchableOpacity, Text } from "react-native";

export function Button({ title }: { title: string }) {
  return (
    <TouchableOpacity className="px-4 py-2 bg-blue-500 rounded">
      <Text className="text-white font-semibold">{title}</Text>
    </TouchableOpacity>
  );
}

Text.tsx

// packages/ui/src/components/Text.tsx
import { Text as RNText, TextProps } from "react-native";

export function Text(props: TextProps) {
  return <RNText className="text-base text-black" {...props} />;
}

View.tsx

// packages/ui/src/components/View.tsx
import { View as RNView, ViewProps } from "react-native";

export function View(props: ViewProps) {
  return <RNView className="p-4 bg-white" {...props} />;
}

index.tsx

// packages/ui/src/index.ts
export { Button } from './components/Button';
export { Text } from './components/Text';
export { View } from './components/View';

Integration

Add to web app:

// apps/web/package.json
{
  "dependencies": {
    "@mammoth/ui": "workspace:*",
    "nativewind": "^4.1.23"
  }
}

Update Tailwind config to include the shared UI package:

// apps/web/tailwind.config.js
/** @type {import('tailwindcss').Config} */
import nativewindPreset from "nativewind/preset";

module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
    "../../packages/ui/src/**/*.{js,jsx,ts,tsx}" // include shared UI
  ],
  presets: [nativewindPreset],
}

Install the dependencies:

# terminal (apps/web)
pnpm install

This ensures Tailwind scans shared components and NativeWind works in the web build.

Usage in web

// apps/web/src/app/page.tsx
"use client"

import { Button, Text, View } from "@mammoth/ui";

export default function MyScreen() {
  return (
    <View className="flex-1 justify-center items-center">
      <Text className="mb-4 text-xl font-bold">Welcome</Text>
      <Button title="Get Started" />
    </View>
  );
}

Result: Browser displaying shared UI components with Welcome text and Get Started button styled with NativeWind

Usage in mobile

First, add the shared UI package to the mobile app:

// apps/mobile/package.json
{
  "dependencies": {
    "@mammoth/ui": "workspace:*"
  }
}

Then install the dependencies:

# terminal (apps/mobile)
pnpm install

Now you can use the shared components:

// apps/mobile/src/app/index.tsx
import { Button, Text, View } from "@mammoth/ui";

  

export default function Index() {
  return (
    <View className="flex-1 bg-white">
      <View className="flex-1 fixed inset-0 flex justify-center items-center">
        <View>
          <Text className="mb-4 text-xl font-bold text-center">Welcome</Text>
          <Button title="Get Started" />
        </View>
      </View>
    </View>
  );
}

Result: iOS Simulator showing the same shared UI components with identical styling as the web version

Conclusion

You now have a fully functional monorepo that shares UI components between Next.js (web) and Expo (mobile). The setup provides code reuse, consistent styling with NativeWind, and intelligent caching through Turborepo.

Architecture Overview

monorepo/
├── apps/
│   ├── web/          # Next.js app with react-native-web
│   └── mobile/       # Expo app with NativeWind
├── packages/
│   └── ui/           # Shared React Native components
└── turbo.json        # Task orchestration config

Key Benefits

  • Write UI components once, use everywhere
  • NativeWind ensures identical appearance across platforms
  • Turborepo's caching significantly reduces build times
  • Single lockfile for simplified dependency management

Daily Development Commands

turbo dev          # Start both apps
turbo check-types  # Type checking
turbo build        # Build all packages
turbo lint         # Lint code

Next Steps

Consider extending with shared config packages and CI/CD with remote caching.

Future Updates: This template will be updated and a new post will be published when NativeWind releases v5 and Expo upgrades to SDK 54, bringing even better performance and developer experience.

Found this helpful? Follow me on X @gurselcakar for more content on building, learning, and life.