React Monorepo from Scratch: Next.js, Expo & NativeWind

By the end of this guide, you’ll have a working monorepo where a single set of React components renders on web, iOS, and Android:

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

We’ll use Next.js and Expo for the apps, NativeWind for cross-platform styling, and Turborepo for builds and caching. The template also includes a Vite + TanStack Router web app as a lighter alternative to Next.js. Familiarity with React, Next.js basics, and the terminal is assumed.

Rather just clone and go? Grab the universal-react-monorepo template.

Prerequisites

  • Node.js 18+, pnpm 10+
  • For mobile: iOS Simulator (macOS only) and/or Android Studio
  • Optional: Turbo CLI, GitHub CLI

Initialise the repository

Create a new project and set up git:

1mkdir mammoth && cd mammoth
2git init
3echo "# mammoth" > README.md
4git add README.md && git commit -m "Initial commit"

If you use GitHub CLI:

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

Initialise the workspace with pnpm:

1pnpm init

Define the workspace

Create pnpm-workspace.yaml to tell pnpm which folders contain apps and packages:

1packages:
2  - "apps/*"
3  - "packages/*"

Create the folders:

1mkdir -p apps packages

Add a .gitignore:

1node_modules

Add Turborepo

Install Turborepo as a dev dependency:

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

Create turbo.json at the root:

 1// turbo.json (repo root)
 2{
 3	"$schema": "https://turborepo.com/schema.json",
 4	"ui": "tui",
 5	"tasks": {
 6		"build": {
 7			"dependsOn": ["^build"],
 8			"inputs": ["$TURBO_DEFAULTS$", ".env*"],
 9			"outputs": [".next/**", "!.next/cache/**", "dist/**"]
10		},
11		"typecheck": {
12			"dependsOn": ["^typecheck"]
13		},
14		"lint": {
15			"dependsOn": ["^lint"]
16		},
17		"dev": {
18			"persistent": true,
19			"cache": false
20		}
21	}
22}

Every script in an app or package that should run across the monorepo must be declared here. The tui view splits logs per task, which becomes useful once multiple apps are running.

Update .gitignore:

1node_modules
2.turbo

For a deeper understanding, read through the Turborepo documentation. There’s also an excellent walkthrough by Michael Guay covering Turborepo with pnpm workspaces.

Add the Next.js app

Create the web app inside apps/:

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

Select the following options: Next.js setup options showing TypeScript enabled, ESLint enabled, Tailwind CSS disabled, src/ directory enabled, App Router enabled, and import alias customisation disabled

Wire scripts to Turborepo

Add root scripts so Turborepo orchestrates tasks across all apps and packages:

1// package.json (repo root)
2{
3	"scripts": {
4		"dev": "turbo dev",
5		"build": "turbo build",
6		"lint": "turbo lint",
7		"typecheck": "turbo typecheck"
8	}
9}

Update the web app scripts. The --webpack flag is needed because react-native-web isn’t compatible with Turbopack:

 1// package.json (apps/web)
 2{
 3	"scripts": {
 4		"dev": "next dev --webpack",
 5		"build": "next build --webpack",
 6		"start": "next start --webpack",
 7		"lint": "eslint",
 8		"typecheck": "tsc --noEmit"
 9	}
10}

Run pnpm install after editing scripts or dependencies to sync the lockfile.

Native builds approval

You might encounter this approve-builds warning: Terminal showing pnpm warning about native dependencies requiring approval before building

This is pnpm’s safeguard for building native binaries. Run:

1# terminal (repo root)
2pnpm approve-builds

This records approved packages in pnpm-workspace.yaml:

1# pnpm-workspace.yaml (repo root)
2packages:
3  - apps/*
4  - packages/*
5
6onlyBuiltDependencies:
7  - sharp
8  - unrs-resolver

First build and caching

Run a full build:

1# terminal (repo root)
2turbo build

The first run is a cache miss: Terminal output showing Turborepo cache miss with ‘MISS’ status for the web:build task

Terminal showing build completion in 6.12s with task summary

Run it again:

1# terminal (repo root)
2turbo build

Cache hit: Terminal output showing Turborepo cache hit with ‘HIT’ status for the web:build task

The cached build replays in 83ms: Terminal showing cached build completion in just 83ms

Turborepo hashes inputs and replays outputs on cache hits. You can enable Remote Caching to share artifacts across machines.

Add the Expo app

Inside apps/, create the mobile app:

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

Expo defaults to npm. Switch to pnpm:

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

Reset the project configuration:

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

Update package.json:

 1// package.json (apps/mobile)
 2{
 3	"scripts": {
 4		"dev": "expo start",
 5		"android": "expo start --android",
 6		"ios": "expo start --ios",
 7		"web": "expo start --web",
 8		"lint": "expo lint",
 9		"typecheck": "tsc --noEmit"
10	}
11}

Run pnpm install, then turbo dev from the repo root to start both apps.

Turborepo TUI

With two apps running, logs from web and mobile overlap: Terminal showing overlapping log output from both web and mobile development servers running simultaneously

The TUI (enabled in turbo.json) separates logs by task. Mobile logs: Turborepo TUI showing mobile development server logs with Expo QR code and Metro bundler information

Press the down arrow to switch to web: 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. Follow the official installation guide for the most up-to-date instructions.

Install the required packages:

1# terminal (apps/mobile)
2pnpm install nativewind react-native-reanimated@~4.1.2 react-native-safe-area-context@5.6.1
3pnpm install -D tailwindcss@^3.4.18 prettier-plugin-tailwindcss@^0.5.14

Initialise Tailwind:

1# terminal (apps/mobile)
2pnpm tailwindcss init

Configure Tailwind:

 1// tailwind.config.js (apps/mobile)
 2/** @type {import('tailwindcss').Config} */
 3module.exports = {
 4  content: [["./src/**/*.{js,jsx,ts,tsx}"]],
 5  presets: [require("nativewind/preset")],
 6  theme: {
 7    extend: {},
 8  },
 9  plugins: [],
10}

Create global styles:

1/* global.css (apps/mobile) */
2@tailwind base;
3@tailwind components;
4@tailwind utilities;

Configure Babel:

 1// babel.config.js (apps/mobile)
 2module.exports = function (api) {
 3  api.cache(true);
 4  return {
 5    presets: [
 6      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
 7      "nativewind/babel",
 8    ],
 9  };
10};

Configure Metro:

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

Add type support:

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

Include it in tsconfig.json:

 1// tsconfig.json (apps/mobile)
 2{
 3  "include": [
 4    "**/*.ts",
 5    "**/*.tsx",
 6    ".expo/types/**/*.ts",
 7    "expo-env.d.ts",
 8    "nativewind-env.d.ts"
 9  ]
10}

Import the global CSS in your root layout:

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

Update index.tsx:

 1// index.tsx (apps/mobile/src/app)
 2import { Text, View } from "react-native";
 3
 4export default function Index() {
 5	return (
 6		<View className="flex-1 items-center justify-center bg-white">
 7			<Text className="text-4xl font-bold">Hello World</Text>
 8		</View>
 9	);
10}

Metro resolution fix

Running turbo dev may produce a Metro resolution error: Terminal showing Metro bundling error about unable to resolve module dependencies in the monorepo

Metro expects a flat node_modules tree. Create .npmrc at the repo root and reinstall:

1# terminal (repo root)
2printf "node-linker=hoisted\n" > .npmrc
3rm -rf node_modules apps/**/node_modules
4pnpm install
5pnpm dev --reset-cache

node-linker=hoisted reduces symlink complexity so Metro resolves packages cleanly. Credit to this GitHub issue for the fix.

The mobile app now runs: iOS Simulator displaying ‘Hello World’ text in large font on a white background

Align React versions

Pin React and React Native versions at the root to prevent duplicate copies and peer conflicts:

 1// package.json (repo root)
 2{
 3  "pnpm": {
 4    "overrides": {
 5      "react": "19.1.0",
 6      "react-dom": "19.1.0",
 7      "react-native": "0.81.5",
 8      "react-native-web": "0.21.1",
 9      "tailwindcss": "3.4.18"
10    }
11  }
12}

Ensure apps/web/package.json uses the same react and react-dom versions, then run pnpm install.

Set up Tailwind and NativeWind in Next.js

Install all dependencies:

1# terminal (apps/web)
2pnpm add react-native-web nativewind
3pnpm add -D tailwindcss@^3 postcss autoprefixer
4npx tailwindcss init -p

NativeWind requires Tailwind v3. If create-next-app installed v4, pin to ^3 explicitly.

Configure Tailwind with the NativeWind preset:

 1// tailwind.config.js (apps/web)
 2/** @type {import('tailwindcss').Config} */
 3import nativewindPreset from "nativewind/preset";
 4
 5module.exports = {
 6  content: ["./src/**/*.{js,jsx,ts,tsx}"],
 7  presets: [nativewindPreset],
 8  theme: {
 9    extend: {},
10  },
11  plugins: [],
12}

Configure PostCSS:

1// postcss.config.js (apps/web)
2module.exports = {
3	plugins: {
4		tailwindcss: {},
5		autoprefixer: {},
6	},
7}

Add global styles:

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

Configure Next.js to resolve react-native to react-native-web and transpile NativeWind:

 1// next.config.ts (apps/web)
 2import type { NextConfig } from 'next'
 3
 4const nextConfig: NextConfig = {
 5	transpilePackages: [
 6		'react-native',
 7		'react-native-web',
 8		'nativewind',
 9		'react-native-css-interop',
10	],
11	webpack: (config) => {
12		config.resolve.alias = {
13			...(config.resolve.alias || {}),
14			'react-native$': 'react-native-web',
15		};
16		config.resolve.extensions = [
17			'.web.ts',
18			'.web.tsx',
19			'.web.js',
20			...config.resolve.extensions,
21		];
22		return config;
23	}
24}
25
26export default nextConfig

Enable NativeWind types in tsconfig.json:

1// tsconfig.json (apps/web)
2{
3  "compilerOptions": {
4    "jsxImportSource": "nativewind",
5    "types": ["nativewind/types"]
6  }
7}

Remove page.module.css and update page.tsx:

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

Result: Browser showing Next.js development server with ‘Hello World’ heading centred on the page

Verify React Native components render through react-native-web:

 1// page.tsx (apps/web/src/app)
 2import { View, Text } from "react-native";
 3
 4export default function Home() {
 5	return (
 6		<div className="flex flex-col items-center justify-center h-screen">
 7			<h1 className="text-4xl font-bold">Hello World</h1>
 8			<View className="flex-1 items-center justify-center bg-white">
 9				<Text>Hello from React Native</Text>
10			</View>
11		</div>
12	);
13}

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

Shared UI package

Create a packages/ui package with reusable React Native components styled by NativeWind. Components run on web (via react-native-web) and mobile without changes.

Structure

1packages/ui/
2├── package.json
3├── tsconfig.json
4├── nativewind-env.d.ts
5└── src/
6    ├── index.ts
7    ├── Button.tsx
8    └── Text.tsx

Setup

Create the folder:

1# terminal (repo root)
2mkdir -p packages/ui/src

package.json:

 1// packages/ui/package.json
 2{
 3  "name": "ui",
 4  "version": "0.0.0",
 5  "private": true,
 6  "main": "./src/index.ts",
 7  "types": "./src/index.ts",
 8  "scripts": {
 9    "lint": "eslint",
10    "typecheck": "tsc --noEmit"
11  },
12  "devDependencies": {
13    "@types/react": "~19.0.10",
14    "eslint": "^9.25.0",
15    "eslint-config-expo": "~9.2.0",
16    "expo": "~54.0.25",
17    "nativewind": "^4.1.23",
18    "typescript": "~5.8.3"
19  },
20  "peerDependencies": {
21    "react": "19.1.0",
22    "react-native": "0.81.5"
23  }
24}

tsconfig.json:

 1// packages/ui/tsconfig.json
 2{
 3  "extends": "expo/tsconfig.base",
 4  "compilerOptions": {
 5    "strict": true,
 6    "noEmit": true
 7  },
 8  "include": [
 9    "src/**/*",
10    "nativewind-env.d.ts"
11  ]
12}

nativewind-env.d.ts:

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

Components

Button.tsx:

 1// packages/ui/src/Button.tsx
 2import { Pressable, Text } from "react-native";
 3
 4export function Button({ title }: { title: string }) {
 5  return (
 6    <Pressable className="px-4 py-2 bg-blue-500 rounded">
 7      <Text className="text-white font-semibold">{title}</Text>
 8    </Pressable>
 9  );
10}

Text.tsx:

1// packages/ui/src/Text.tsx
2import { Text as RNText, TextProps } from "react-native";
3
4export function Text(props: TextProps) {
5  return <RNText className="text-base text-black" {...props} />;
6}

index.ts:

1// packages/ui/src/index.ts
2export { Button } from './Button';
3export { Text } from './Text';

The template repository includes additional components (Card, Badge, Input) with variant-based styling using tailwind-variants. The examples above are kept simple to focus on the cross-platform setup.

Integration

Add the UI package to the web app:

1// apps/web/package.json
2{
3  "dependencies": {
4    "ui": "workspace:*"
5  }
6}

Update the web Tailwind config to scan shared components:

 1// tailwind.config.js (apps/web)
 2/** @type {import('tailwindcss').Config} */
 3import nativewindPreset from "nativewind/preset";
 4
 5module.exports = {
 6  content: [
 7    "./src/**/*.{js,jsx,ts,tsx}",
 8    "../../packages/ui/src/**/*.{js,jsx,ts,tsx}"
 9  ],
10  presets: [nativewindPreset],
11}

Run pnpm install.

Usage in web

 1// apps/web/src/app/page.tsx
 2"use client"
 3
 4import { View } from "react-native";
 5import { Button, Text } from "ui";
 6
 7export default function Home() {
 8  return (
 9    <View className="flex-1 justify-center items-center h-screen">
10      <Text className="mb-4 text-xl font-bold">Welcome</Text>
11      <Button title="Get Started" />
12    </View>
13  );
14}

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

Usage in mobile

Add the UI package to the mobile app:

1// apps/mobile/package.json
2{
3  "dependencies": {
4    "ui": "workspace:*"
5  }
6}

Run pnpm install.

 1// apps/mobile/src/app/index.tsx
 2import { View } from "react-native";
 3import { Button, Text } from "ui";
 4
 5export default function Index() {
 6  return (
 7    <View className="flex-1 justify-center items-center bg-white">
 8      <Text className="mb-4 text-xl font-bold">Welcome</Text>
 9      <Button title="Get Started" />
10    </View>
11  );
12}

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

Wrapping up

That covers the core setup. Here’s what you’ve built:

1monorepo/
2├── apps/
3│   ├── web/          # Next.js with react-native-web
4│   └── mobile/       # Expo (React Native)
5├── packages/
6│   └── ui/           # Shared components
7└── turbo.json        # Task orchestration
1turbo dev          # Start both apps
2turbo build        # Build all packages
3turbo typecheck    # Type checking
4turbo lint         # Lint code

The template is meant to be a starting point, so fork it and make it yours. You could add shared packages for utilities or API clients, set up shared ESLint and Prettier configs, wire up CI/CD with GitHub Actions and Turborepo remote caching, or swap NativeWind for something like Tamagui or UniWind. The template also includes a Vite + TanStack Router web app (apps/web-vite) if you prefer something lighter than Next.js.

I built this by learning as I went, piecing together docs, debugging pnpm symlinks, and figuring out which config goes where. This post and the template are my way of sharing what I learned so you can skip the parts that aren’t fun. It’s not a polished product, it’s a head start. If it saves you even an hour of setup, it was worth writing.

If you found this useful, give the repo a star. If something’s broken, open an issue.

You can also follow me on X (formerly Twitter) where I post about other things I’m working on. Speaking of which, check out my games: Hukora, a logic-based puzzle game, and Arithmego, an arithmetic game for the terminal.