Building a Universal React Monorepo with Next.js and Expo
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:
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:
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:
The initial build took 6 seconds:
Now let's run it again:
# terminal (repo root)
turbo build
This time around notice the cache hit:
The cached build is nearly instant, taking only 83ms:
Turborepo computes a hash of inputs and replays prior outputs/logs on cache hits. This applies to any task (build
, check-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.
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.
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:
By pressing the down arrow you can switch to the web logs:
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:
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 )
Why: Metro expects a flat node_modules
. node-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:
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:
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:
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:
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:
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:
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.