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.

Project Folder Structure

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

Essential Configuration Files

turbo.json

This 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"
  }
}

Installation & Setup Steps

I recommend using pnpm for monorepos because it handles symlinks much more efficiently than npm.

  1. Initialize the Monorepo:

    Bash
    npx create-turbo@latest
    

    Select "pnpm" and follow the prompts.

  2. Add Expo (Mobile):

    Inside apps/, run:

    Bash
    npx create-expo-app mobile --template tabs
    
  3. Add Web:

    Inside apps/, run:

    Bash
    npx create-next-app@latest web
    
  4. Install Shared Dependencies:

    To use the shared ui package inside your mobile app:

    pnpm add @repo/ui --filter mobile
    

Platform-Specific Strategies

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

Professional-Tips for Success

  • 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:

The Shared Configuration (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: [],
};

Web Setup (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
};

Mobile Setup (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")],
};

Integration Workflow

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:

    TypeScript
    /// <reference types="nativewind/types" />
    

Example Shared Component (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 native TouchableOpacity.

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.

The Metro Config (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;

The Babel 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", 
    ],
  };
};

Shared TypeScript Config (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/*"]
    }
  }
}

Summary Checklist for Connectivity

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.

The Shared API Client (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;
};

The Shared Hooks (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,
  });
};

Implementation in Apps

For Web (Next.js)

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>;
};

For Mobile (Expo)

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>;
};

Handling Platform-Specific Logic

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.

Benefits Checklist

  • 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.

The CI Workflow (.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"

The Final Skeleton (Summary)

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

Immediate Next Steps to Launch

Once you've pushed this code to GitHub, follow these two final steps to see it live:

  1. Deploy Web: Connect your repo to Vercel. It will automatically detect the Turborepo and deploy your apps/web folder to a URL.

  2. 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.

Professional-Tip: The "Shared Logic" Rule

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.

🚀 Universal Full-Stack Monorepo

A high-performance monorepo for building Web (Next.js) and Mobile (Expo/React Native) apps with shared logic and a unified design system.

🏗️ Architecture

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

🛠️ Getting Started

Prerequisites

  • 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)

Installation

From the root directory, run:

Bash

pnpm install

Development

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

🧪 Development Workflow

Shared UI Components

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.

API & Data Fetching

Avoid writing fetch or useQuery inside the apps/ folder.

  1. Define the query/mutation in packages/api.

  2. Export the hook.

  3. Import it in both Web and Mobile apps for consistent data state.

Useful Commands

Command Action
pnpm build Build all apps and packages
pnpm lint Lint the entire monorepo
pnpm clean Wipe all node_modules and build caches

🚀 Deployment

Web (Vercel)

Connect the root of this repo to Vercel. It will automatically detect the Turborepo settings and deploy apps/web.

Mobile (EAS)

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 install at 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.

🏁 The Final Project Blueprint

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.

📂 Complete Folder Visualization

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)

🛠 The "One-Command" Developer Experience

With this skeleton, your day-to-day workflow becomes incredibly streamlined:

  1. Start Everything: Run pnpm dev at the root. Turborepo will start the Next.js dev server and the Expo Metro bundler simultaneously.

  2. 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.

  3. Push to Production: Your GitHub Action automatically lints and builds both platforms to ensure no breaking changes were introduced.

🚀 Final Deployment Strategy

  • 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.

🛑 Common Pitfall to Avoid

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.