The Skeleton of React-Native Project Structure
Building a "Universal" or Monorepo structure for React (Web) and React Native (Mobile) is the gold standard for modern development. It allows you to share logic, types, and even components while keeping platform-specific code isolated.
Here is a comprehensive overview of a Turborepo-based Monorepo—the most efficient way to manage this dual-environment setup.
A monorepo uses a packages/ folder for shared code and an apps/ folder for the entry points.
my-universal-app/
├── apps/
│ ├── web/ # React (Next.js or Vite)
│ └── mobile/ # React Native (Expo)
├── packages/
│ ├── ui/ # Shared UI components (Atomic design)
│ ├── utils/ # Shared helper functions/hooks
│ ├── config/ # Shared ESLint, TypeScript, Tailwind configs
│ └── api/ # Shared API clients (Axios/React Query)
├── package.json # Root dependencies (Workspaces)
├── turbo.json # Turborepo build pipeline
└── pnpm-workspace.yaml # Workspace definition
turbo.jsonThis file orchestrates the builds. It ensures that if you change a shared utility, both the web and mobile apps know they need to rebuild.
JSON
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "build/**"]
},
"dev": {
"cache": false
}
}
}
package.json (Root)You'll use Workspaces to link the folders.
JSON
{
"name": "my-universal-app",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"devDependencies": {
"turbo": "latest"
}
}
I recommend using pnpm for monorepos because it handles symlinks much more efficiently than npm.
Initialize the Monorepo:
Bash
npx create-turbo@latest
Select "pnpm" and follow the prompts.
Add Expo (Mobile):
Inside apps/, run:
npx create-expo-app mobile --template tabs
Add Web:
Inside apps/, run:
npx create-next-app@latest web
Install Shared Dependencies:
To use the shared ui package inside your mobile app:
pnpm add @repo/ui --filter mobile
| Feature | Web Strategy | Mobile Strategy | Shared Solution |
| Styling | CSS Modules / Tailwind | StyleSheet / Tailwind | NativeWind (Tailwind for both) |
| Navigation | Next.js Link / Router | React Navigation / Expo Router | Expo Router (Unified routing) |
| State | Redux / Zustand | Redux / Zustand | Shared logic in packages/store |
| Icons | Lucide React | Expo Vector Icons | Lucide-React-Native |
NativeWind: If you love Tailwind, use NativeWind. It allows you to write className="text-red-500" and have it work on both a <div> in Next.js and a <Text> in React Native.
The .native.ts trick: If a piece of logic is fundamentally different (e.g., file picking), create filePicker.ts for web and filePicker.native.ts for mobile. React Native's bundler (Metro) will automatically pick the .native version.
Shared API Layer: Don't write your fetch calls twice. Build a packages/api using TanStack Query (React Query). It works identically on both platforms.
To get NativeWind (Tailwind CSS) working seamlessly across Next.js and React Native in a monorepo, you need a shared configuration that understands both web-standard CSS and React Native's styling engine.
Here is the setup for a unified design system:
packages/config/tailwind.config.js)Instead of duplicating your theme, export a base config from your shared packages.
JavaScript
// packages/config/tailwind.config.js
module.exports = {
content: [
"../../apps/web/**/*.{js,jsx,ts,tsx}",
"../../apps/mobile/**/*.{js,jsx,ts,tsx}",
"../../packages/ui/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: "#0070f3",
secondary: "#1db954",
},
},
},
plugins: [],
};
apps/web/tailwind.config.js)In your Next.js app, you import the base config and add the standard Tailwind directives.
JavaScript
const sharedConfig = require("@repo/config/tailwind.config");
module.exports = {
...sharedConfig,
// Add web-specific overrides here if needed
};
apps/mobile/tailwind.config.js)For React Native, you must wrap the config with the nativewind/babel preset in your babel.config.js, but the tailwind file remains similar:
JavaScript
const sharedConfig = require("@repo/config/tailwind.config");
module.exports = {
...sharedConfig,
presets: [require("nativewind/preset")],
};
To make this functional across the monorepo, follow these three steps:
Babel Configuration (Mobile): In apps/mobile/babel.config.js, add nativewind/babel to your plugins list. This is what translates className into StyleSheet objects at runtime.
Global CSS (Web): Import your globals.css in apps/web/pages/_app.js (or the App Router layout) as usual.
Type Safety: Create a nativewind-env.d.ts file in your mobile and UI packages to ensure TypeScript doesn't complain when you add className to React Native components:
/// <reference types="nativewind/types" />
packages/ui/Button.tsx)Now you can write a component once and use it in both apps:
TypeScript
import { Text, TouchableOpacity } from 'react-native';
export const StyledButton = ({ title, onPress }) => {
return (
<TouchableOpacity
onPress={onPress}
className="bg-primary p-4 rounded-xl active:bg-secondary"
>
<Text className="text-white font-bold text-center">
{title}
</Text>
</TouchableOpacity>
);
};
Note: On Web, NativeWind (via
react-native-web) will render this as a<div role="button">, while on Mobile it remains a nativeTouchableOpacity.
Configuring Metro (the React Native bundler) is the trickiest part of a monorepo. By default, Metro doesn't "look" outside its own folder, so it won't see your packages/ui or packages/config folders without a little nudge.
Here is the configuration to bridge the gap.
apps/mobile/metro.config.js)This tells Metro to watch the root of the monorepo and resolve workspaces correctly.
JavaScript
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
// Find the project and workspace root
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages from
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve modules from the node_modules folder
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
apps/mobile/babel.config.js)This enables NativeWind and ensures the shared packages are transpiled correctly for the mobile environment.
JavaScript
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
// Required for Expo Router and shared package resolution
"react-native-reanimated/plugin",
],
};
};
packages/config/tsconfig.base.json)To avoid red squiggles in your editor when importing shared packages, ensure your base TS config is robust.
JSON
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "react-native",
"baseUrl": ".",
"paths": {
"@repo/ui/*": ["../ui/src/*"],
"@repo/utils/*": ["../utils/src/*"]
}
}
}
To ensure your apps can "talk" to your shared packages:
Export Everything: Make sure your packages/ui/package.json has a "main": "./index.tsx" and that your components are exported there.
Pnpm Linking: After adding a dependency to apps/mobile/package.json, always run pnpm install from the root to refresh the symlinks.
Clear Cache: If things get weird, run npx expo start -c to clear the Metro cache.
Sharing your API layer is the single biggest time-saver in a monorepo. By placing your TanStack Query (React Query) logic in packages/api, you ensure that data fetching, caching, and synchronization logic are identical on both platforms.
packages/api/src/client.ts)First, create a base Axios or Fetch instance.
TypeScript
import axios from 'axios';
export const api = axios.create({
baseURL: 'https://api.yourdomain.com',
headers: {
'Content-Type': 'application/json',
},
});
// Example shared fetcher
export const fetchUser = async (id: string) => {
const { data } = await api.get(`/users/${id}`);
return data;
};
packages/api/src/queries/useUser.ts)This is where the magic happens. You define the hook once, and it manages state for both platforms.
TypeScript
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from '../client';
export const useUser = (userId: string) => {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
};
In apps/web/pages/_app.tsx, wrap your app with the QueryClientProvider. Then, in any component:
TypeScript
import { useUser } from '@repo/api';
const Profile = () => {
const { data, isLoading } = useUser('123');
if (isLoading) return <p>Loading...</p>;
return <h1>{data.name}</h1>;
};
In apps/mobile/App.tsx, use the exact same setup.
TypeScript
import { Text } from 'react-native';
import { useUser } from '@repo/api';
const ProfileScreen = () => {
const { data, isLoading } = useUser('123');
if (isLoading) return <Text>Loading...</Text>;
return <Text>{data.name}</Text>;
};
Sometimes, you need to handle things differently (like secure storage for tokens). You can pass a "config" object to your shared API package or use the .native.ts strategy:
packages/api/src/storage.ts: Uses localStorage (Web).
packages/api/src/storage.native.ts: Uses expo-secure-store (Mobile).
Metro and Webpack will automatically pick the correct file based on the extension.
Single Source of Truth: Change a URL or a data transformation in one place, and it updates everywhere.
Typed Responses: If you use TypeScript, your API responses are strictly typed across the whole monorepo.
Consistent Caching: Both apps will follow the same staleTime and cacheTime logic.
To wrap everything up, we’ll set up a GitHub Action that acts as your "Continuous Integration" (CI) gatekeeper. Since this is a monorepo, we use Turborepo's turbo run command to intelligently build only the apps that have changed, saving you hours of CI time.
.github/workflows/ci.yml)Create this file at the very root of your project.
YAML
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
name: Compile and Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Lint, Test, and Build
# Turbo handles the order: UI -> API -> Apps
run: pnpm turbo run lint test build --cache-dir=".turbo"
Now that we've built the pieces, here is what your complete "Full-Stack Mobile & Web" architecture looks like:
| Layer | Responsibility | Key Tech |
| Orchestration | Task running and caching | Turborepo |
| Shared UI | Design system & cross-platform components | NativeWind |
| Shared Data | API hooks, caching, and types | TanStack Query |
| Web Entry | SEO, routing, and browser experience | Next.js |
| Mobile Entry | Native performance and hardware access | Expo |
Once you've pushed this code to GitHub, follow these two final steps to see it live:
Deploy Web: Connect your repo to Vercel. It will automatically detect the Turborepo and deploy your apps/web folder to a URL.
Deploy Mobile: Run npx eas build inside apps/mobile. This uses Expo Application Services to compile your .ipa (iOS) or .apk (Android) files in the cloud.
As you develop, always ask yourself: "Is this code specific to a screen, or is it a rule?"
Validation Logic? Put it in packages/utils.
User Data Fetching? Put it in packages/api.
A Button with a specific brand color? Put it in packages/ui.
This discipline ensures that 80% of your app is written once, while only the navigation and platform-specific views (like a Camera or Map) are written twice.
Here is a professional, high-clarity README.md to place at the root of your monorepo. It serves as the "source of truth" for you and any future collaborators.
A high-performance monorepo for building Web (Next.js) and Mobile (Expo/React Native) apps with shared logic and a unified design system.
This project uses Turborepo with pnpm workspaces to manage shared packages and applications.
apps/web: Next.js (App Router)
apps/mobile: Expo (React Native) with Expo Router
packages/ui: Shared components using NativeWind (Tailwind CSS)
packages/api: Shared TanStack Query hooks and Axios client
packages/config: Shared ESLint, TypeScript, and Tailwind configurations
https://pnpm.io/installation (Required for workspace management)
https://nodejs.org/ (v20 or later)
https://www.google.com/search?q=https://expo.dev/client (For mobile testing)
From the root directory, run:
Bash
pnpm install
You can run all platforms simultaneously or focus on one:
Run All: pnpm dev
Web Only: pnpm dev --filter web
Mobile Only: pnpm dev --filter mobile
When adding a component to packages/ui, ensure you export it from index.tsx.
Because we use NativeWind, your classes like className="text-primary" will automatically map to CSS on Web and StyleSheet on Mobile.
Avoid writing fetch or useQuery inside the apps/ folder.
Define the query/mutation in packages/api.
Export the hook.
Import it in both Web and Mobile apps for consistent data state.
| Command | Action |
pnpm build |
Build all apps and packages |
pnpm lint |
Lint the entire monorepo |
pnpm clean |
Wipe all node_modules and build caches |
Connect the root of this repo to Vercel. It will automatically detect the Turborepo settings and deploy apps/web.
Navigate to apps/mobile and use Expo Application Services:
Bash
npx eas build --platform ios # or android
Note: If you add new dependencies to a package, run
pnpm installat the root to update the workspace symlinks.
To finalize your project, here is the complete "Source of Truth" summary. We have built a Universal React Architecture that allows you to ship to the App Store, Play Store, and the Web from a single codebase.
| Layer | Technology | Role |
| Monorepo Engine | Turborepo + pnpm | Handles smart caching and cross-package linking. |
| Web App | Next.js (App Router) | High-performance SEO and web presence. |
| Mobile App | Expo (React Native) | Native iOS/Android builds with Expo Router. |
| Shared UI | NativeWind (Tailwind) | Write CSS once; renders as CSS on Web and Stylesheets on Mobile. |
| Shared Logic | TanStack Query | Centralized API calls, caching, and data synchronization. |
Your final structure should look like this:
.
├── apps/
│ ├── web/ (Next.js)
│ └── mobile/ (Expo)
├── packages/
│ ├── ui/ (Shared Components)
│ ├── api/ (Shared Data Fetching)
│ ├── config/ (Shared TS/ESLint/Tailwind configs)
│ └── utils/ (Shared Helpers)
├── .github/workflows/ci.yml (The CI/CD gatekeeper)
├── turbo.json (The build pipeline)
└── pnpm-workspace.yaml (The workspace map)
With this skeleton, your day-to-day workflow becomes incredibly streamlined:
Start Everything: Run pnpm dev at the root. Turborepo will start the Next.js dev server and the Expo Metro bundler simultaneously.
Add a Feature:
Create the UI in packages/ui.
Create the Data Hook in packages/api.
Import them into apps/web/page.tsx and apps/mobile/index.tsx.
Push to Production: Your GitHub Action automatically lints and builds both platforms to ensure no breaking changes were introduced.
Web: Push to Vercel. It recognizes the turbo.json and handles the build automatically.
Mobile: Use EAS (Expo Application Services). Run eas build to generate your .ipa and .apk files without needing a local Mac or Android setup.
The "Heavy" Root: Never install app-specific dependencies (like react-dom or react-native-reanimated) in the root package.json. Always use pnpm add [pkg] --filter [app-name] to keep your bundles lean and your dependencies isolated.
You are now equipped with a professional-grade production skeleton.