Back to Blog
React Native Monorepos: The Starter Kit That Actually Works
7 min readJul 17, 20269 views

React Native Monorepos: The Starter Kit That Actually Works

Setting up a React Native monorepo with Expo, Next.js, and a shared UI can be a nightmare. This post cuts through the complexity, showing you a modern starter kit that makes cross-platform development genuinely streamlined.

ReactReact NativeNext.jsMonorepoTooling
Share

by Sunil Band

The Monorepo Dream for React Native

We've all been there: you're building a web app with Next.js, and now you need a mobile version with React Native. You start duplicating components, rewriting logic, and suddenly your 'shared' design system isn't so shared anymore. Or maybe you're building multiple React Native apps that share a core library. Copy-pasting isn't scaling, and npm link feels like a hack from 2015.

The promise of a monorepo is tantalizing: a single codebase where your web app, mobile app, and shared UI components all live in harmony. You write a component once, and it runs everywhere. You share utility functions without publishing private packages. Your design system is truly unified. In theory, it's elegant. In practice, setting up a React Native monorepo, especially with Expo and Next.js, often feels like trying to assemble IKEA furniture with half the instructions missing and a few crucial pieces swapped out. It's a frustrating mess of symlinks, metro configs, and TypeScript path aliases that never quite click.

I've spent way too much time debugging these setups, and frankly, I'm tired of it. What we need isn't just a collection of tools, but a cohesive starter kit that pre-wires everything, letting us focus on building features, not fighting build systems. That's why I've been exploring a particular monorepo template that genuinely simplifies this complex dance.

The Core Ingredients: Expo, Next.js, and Tamagui

Before diving into the how, let's talk about the why behind the key players in this setup. Each brings something crucial to the table, and their combined strengths are what make the monorepo dream a reality.

Expo has become the de-facto standard for React Native development, especially for new projects. It abstracts away a ton of native build complexity, provides a fantastic development experience, and supports a vast ecosystem of libraries. For monorepos, Expo's expo-router is a game-changer, simplifying navigation across web and native. Critically, Expo also has excellent support for web builds using React Native for Web, which is essential for code sharing with a Next.js app.

Next.js is my go-to for web applications. Its file-system based routing, server components, and data fetching primitives make it incredibly productive. When integrating with a React Native monorepo, Next.js acts as the web host for our shared UI, ensuring a consistent experience.

The real magic, however, comes from Tamagui. I've talked about Tamagui before, and for good reason. It's a UI library and styling system built specifically for universal React applications. Unlike Material UI or Ant Design, Tamagui is designed from the ground up to provide platform-specific optimizations for web (via React DOM) and native (via React Native). Its compile-time optimizations drastically reduce bundle size and improve performance, which is a major win for both web and mobile. It also provides a robust design system infrastructure that can be shared across all platforms, something vanilla CSS-in-JS or Tailwind often struggle with in a truly universal context.

The Starter Kit Structure

The template I've been using leverages create-universal-app by Fernando Rojo, a core contributor to Tamagui. It’s opinionated, which is exactly what you want when battling monorepo complexity. It sets up a structure that looks something like this:

plaintext
monorepo/
├── apps/
│   ├── expo/          # The React Native / Expo app
│   └── next/          # The Next.js web app
└── packages/
    ├── app/           # Shared UI components and logic (built with Tamagui)
    ├── config/        # Shared configs (ESLint, TSConfig, etc.)
    └── ui/            # Another example shared package (e.g., specific web-only UI)

This structure clearly separates the platform-specific applications (apps/) from the reusable code (packages/). The packages/app directory is where the majority of your universal components and business logic will live, built with Tamagui.

Let's look at a minimal packages/app/features/home/HomeScreen.tsx component that can be rendered universally:

plaintext
import { Stack, Text, YStack } from 'tamagui'; // Import Tamagui components

export function HomeScreen() {
  return (
    <YStack flex={1} alignItems="center" justifyContent="center" padding="$4">
      <Text fontSize="$8" color="$blue10">
        Hello, Universal World!
      </Text>
      <Stack marginTop="$4">
        <Text fontSize="$5" color="$gray10">
          This component runs on Web and Native.
        </Text>
      </Stack>
    </YStack>
  );
}

Notice we're using Tamagui's Stack, Text, and YStack components, along with its built-in styling props (flex, fontSize, color, padding). These components are designed to work seamlessly across React DOM and React Native, abstracting away the underlying platform differences.

Now, how do we get this into our Next.js and Expo apps?

Integrating into Next.js

In your apps/next directory, your app/page.tsx might look like this:

plaintext
import { HomeScreen } from 'app/features/home/HomeScreen'; // Import from the shared 'app' package
import { TamaguiProvider } from 'app/provider'; // Your shared Tamagui provider

export default function Page() {
  return (
    <TamaguiProvider defaultTheme="light">
      <HomeScreen />
    </TamaguiProvider>
  );
}

The key here is the import app/features/home/HomeScreen. The monorepo setup uses TypeScript path aliases and tooling like next-transpile-modules (or similar for newer Next.js versions) to correctly resolve and transpile code from packages/app within the Next.js build. The TamaguiProvider is also shared, ensuring your theme and design tokens are consistent.

Integrating into Expo

For the Expo app (apps/expo), you'd typically use expo-router. A root layout file like apps/expo/app/_layout.tsx would wrap your app with the shared Tamagui provider:

plaintext
import React from 'react';
import { TamaguiProvider } from 'app/provider'; // Shared Tamagui provider
import { Stack } from 'expo-router';
import { useColorScheme } from 'react-native';

export default function Layout() {
  const colorScheme = useColorScheme();

  return (
    <TamaguiProvider defaultTheme={colorScheme === 'dark' ? 'dark' : 'light'}>
      <Stack /> {/* expo-router's main stack navigator */}
    </TamaguiProvider>
  );
}

Then, a specific route, say apps/expo/app/index.tsx, would simply render the shared HomeScreen:

plaintext
import { HomeScreen } from 'app/features/home/HomeScreen'; // Import from shared 'app' package

export default function Index() {
  return <HomeScreen />;
}

This setup allows expo-router to manage navigation, while the actual screen content comes from your universal packages/app.

The Real Win: Platform-Specific Implementations

One of the biggest headaches in universal development is handling platform-specific code. Often, a component needs a slightly different implementation or prop on web versus native. Tamagui, combined with Expo's file extensions, makes this surprisingly elegant.

Consider a simple button. On the web, you might want it to render as a <button> HTML element for accessibility and semantics. On native, it would be a <Pressable> or Button from React Native. With Tamagui, you can create a shared Button component in packages/app/components/Button.tsx and use platform-specific files:

plaintext
// packages/app/components/Button.tsx
export { Button } from './Button.web'; // Default export for platforms without specific files
plaintext
// packages/app/components/Button.web.tsx (for Next.js)
import { Button as TamaguiButton, ButtonProps } from 'tamagui';

export function Button(props: ButtonProps) {
  return <TamaguiButton tag="button" {...props} />; // Explicitly render as a <button> tag
}
plaintext
// packages/app/components/Button.native.tsx (for Expo)
import { Button as TamaguiButton, ButtonProps } from 'tamagui';

export function Button(props: ButtonProps) {
  return <TamaguiButton {...props} />; // Tamagui handles native rendering automatically
}

Now, when you import Button from app/components/Button in your Next.js app, it will resolve to Button.web.tsx. In your Expo app, it will resolve to Button.native.tsx. This pattern keeps your component API consistent while allowing for necessary platform-specific underlying implementations. It's clean, maintainable, and prevents a lot of Platform.OS === 'web' conditional rendering spaghetti.

Trade-offs and Gotchas

While this setup is powerful, it's not without its complexities. The initial learning curve for Tamagui, especially its styling system and compile-time features, can be steep if you're coming from plain CSS or Tailwind. You're adopting a more opinionated way of styling, which requires a mental shift.

Build times can also be longer, especially during the initial setup or when making significant changes to shared packages. The inter-package dependencies and the need for tools like next-transpile-modules add overhead. Debugging path resolution issues or build errors in a monorepo can sometimes be more challenging than in a single-package project, simply due to the increased surface area of configuration. You'll spend more time understanding how tsconfig.json files interact across packages.

Finally, while the universal component story is strong, not everything will be universally shared. You'll still have platform-specific APIs (e.g., file system access on native, browser APIs on web). The goal isn't 100% code sharing, but maximizing it where it makes sense, particularly for UI and business logic.

Wrapping up

Setting up a truly universal React Native and Next.js monorepo is a challenge. But with the right starter kit and tools like Expo and Tamagui, it becomes a manageable, even enjoyable, process. The create-universal-app template, in particular, offers a fantastic head start by pre-configuring much of the boilerplate that normally causes headaches.

If you're looking to build cross-platform applications without the constant friction of maintaining separate codebases, I highly recommend cloning the create-universal-app template and exploring its structure. Run npx create-universal-app to get started, then spend some time understanding how the packages/app components are shared and how Tamagui simplifies styling across platforms. It's a significant leap forward for universal development and will save you countless hours of configuration pain.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕