DEV Community

Cover image for Building a universal React app with Expo, Next.js & Nativewind
Adebayo Ilerioluwa
Adebayo Ilerioluwa

Posted on • Edited on • Originally published at blog.adebayo.dev

Building a universal React app with Expo, Next.js & Nativewind

Introduction

Building universal React applications has never been easier or more efficient, thanks to Expo. Expo is a powerful toolchain that simplifies the development process, allowing developers to create high-quality, performant apps for iOS, Android, and the web with a single codebase.

With this guide, we will set up a monorepo from scratch to build a Universal React app using Expo and Next.js using tools like NativeWind/Tailwind, Turborepo for building apps across both mobile and web platforms.

Problem

At my job, I was assigned the task of building a design system for both our mobile and web products. Given my background as a React developer, React Native was the natural choice for mobile development.

The challenge was to create a shared component library with consistent styling that works seamlessly across both mobile and web applications using React and React Native.

The goal was to develop a solution that supports the development of both mobile and web applications without duplicating components, rewriting business logic, or maintaining separate codebases.

Before we get started, let's familiarise ourselves with some key terminologies.

Universal in this case means it works on all platforms i.e Andriod, IOS, Web and others.

Expo

Expo is a framework that makes developing Android and iOS apps easier

Next.js

Next.js is a React framework for building full-stack web applications.

React Native for Web

Makes it possible to run React Native components and APIs on the web using React DOM.

Prerequisites

  • Node.js (>=18)
  • Yarn (v1.22.19)
  • Native Development Environment (Xcode, Android Studio e.t.c)

Setup yarn workspaces

We need to initialise our project with a package.json file

yarn init
Enter fullscreen mode Exit fullscreen mode

Using Classic yarn as Expo documentation recommends it.

We currently have first-class support for Yarn 1 (Classic) workspaces. If you want to use another tool, make sure you know how to configure it.

yarn set version 1.22.19
Enter fullscreen mode Exit fullscreen mode

Set private flag as true

+ "private": true
Enter fullscreen mode Exit fullscreen mode

Note that the private: true is required, Workspaces are not meant to be published.

Create sub folders apps and packages

  "workspaces": [
    "apps/*",
    "packages/*"
  ],
Enter fullscreen mode Exit fullscreen mode

packages/* simply means we'll reference all packages from a single directive

apps contains

  • web
  • native

packages contains

  • ui
  • utils e.t.c

Install Turborepo

turbo is built on top of Workspaces, a feature of package managers in the JavaScript ecosystem that allows you to group multiple packages in one repository.

yarn add turbo --dev
Enter fullscreen mode Exit fullscreen mode

Add turbo.json file

{
    "$schema": "https://turbo.build/schema.json",
    "tasks": {
      "build": {
        "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
        "dependsOn": ["^build"]
      },
      "dev": {
        "cache": false,
        "persistent": true
      },
      "lint": {},
      "clean": {
        "cache": false
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Update .gitignore file

+ .turbo
Enter fullscreen mode Exit fullscreen mode

Setup Default Typescript config in the root workspace

{
    "compilerOptions": {
      "strictNullChecks": true,
      "noUncheckedIndexedAccess": true,
      "baseUrl": "./packages",
      "paths": {
        "ui/*": ["./packages/ui/*"]
      },
      "jsx": "react-jsx"
    },
    "extends": "expo/tsconfig.base"
  }
Enter fullscreen mode Exit fullscreen mode

Setting up Packages

Create new shared packages for the monorepo in packages folder containing

ui
app

cd into packages/ui

run

yarn init -y
Enter fullscreen mode Exit fullscreen mode

Next, create an empty index.ts in packages/ui file for now

Structure of Monorepo

universal-app-starter
└── apps
    ├── native 
    └── web
└── packages
    ├── ui 
    └── app
Enter fullscreen mode Exit fullscreen mode

Setup default apps for native and web with Expo & Next.js

Navigate to the apps directory

cd apps
Enter fullscreen mode Exit fullscreen mode

Setting up Nextjs app

Run

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Selection for create next app

Update tsconfig.json to include

  "extends": "../../tsconfig.json",
Enter fullscreen mode Exit fullscreen mode
cd apps/web

yarn run dev
Enter fullscreen mode Exit fullscreen mode

Using Expo

npx create-expo-app@latest
Enter fullscreen mode Exit fullscreen mode

You'll be prompted to enter your app name. Set your app name as native and run the command to reset it as fresh project

yarn run reset-project
Enter fullscreen mode Exit fullscreen mode

Optionally, you could delete the boilerplate files and folders generated from create-expo-app

  • /app-example.
  • components
  • hooks
  • constants
  • scripts

replace tsconfig.json in the native folder

{
  "extends": "../../tsconfig.json",
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Ensure you have expo-env.d.ts file

/// <reference types="expo/types" />

// NOTE: This file should not be edited and should be in your git ignore
Enter fullscreen mode Exit fullscreen mode

To run your project, navigate to the directory and run one of the following commands.

cd native
Enter fullscreen mode Exit fullscreen mode
- yarn run android
- yarn run ios
- yarn run web
Enter fullscreen mode Exit fullscreen mode

Setting Up React Native Web in Next.js app

In the root directory, add resolutions to package.json file

  "resolutions": {
    "react": "18.2.0",
    "react-native": "0.74.2",
    "react-native-web": "~0.19.10",
    "tailwindcss": "^3.4.1"
  }
Enter fullscreen mode Exit fullscreen mode

In apps/web
Run

yarn add react-native-web @expo/next-adapter
Enter fullscreen mode Exit fullscreen mode

Updating Next.js Configuration

Edit next.config.js

/** @type {import('next').NextConfig} */

const { withExpo } = require("@expo/next-adapter");

module.exports = withExpo({
  reactStrictMode: true, 
  transpilePackages: [
    // NOTE: you need to list `react-native` because `react-native-web` is aliased to `react-native`.
    "react-native",
    "react-native-web",
    "ui"
    // Add other packages that need transpiling
  ],
  webpack: (config) => {
    config.resolve.alias = {
      ...(config.resolve.alias || {}),
      // Transform all direct `react-native` imports to `react-native-web`
      "react-native$": "react-native-web",
      "react-native/Libraries/Image/AssetRegistry":
        "react-native-web/dist/cjs/modules/AssetRegistry" // Fix for loading images in web builds with Expo-Image
    };
    config.resolve.extensions = [
      ".web.js",
      ".web.jsx",
      ".web.ts",
      ".web.tsx",
      ...config.resolve.extensions
    ];
    return config;
  }
});
Enter fullscreen mode Exit fullscreen mode

Resetting React Native Web styles

The package react-native-web builds on the assumption of reset CSS styles, here's how you reset styles in Next.js

Add to globals.css

  html, body, #__next {
    width: 100%;
    -webkit-overflow-scrolling: touch;
    margin: 0px;
    padding: 0px;
    min-height: 100%;
}

#__next {
    flex-shrink: 0;
    flex-basis: auto;
    flex-direction: column;
    flex-grow: 1;
    display: flex;
    flex: 1;
}

html {
    -webkit-text-size-adjust: 100%;
    height: 100%;
}

body {
    display: flex;
    overflow-y: auto;
    overscroll-behavior-y: none;
    text-rendering: optimizeLegibility;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    -ms-overflow-style: scrollbar;
}
Enter fullscreen mode Exit fullscreen mode

Creating first shared component

Now we have RNW(React Native Web), Let's write our first shared component.

Create a file view/index.tsx and in the ui package

import { View as ReactNativeView } from 'react-native'

export const View = ReactNativeView;
Enter fullscreen mode Exit fullscreen mode

Update the packages/ui/index.ts

Add

export {};
Enter fullscreen mode Exit fullscreen mode

Using ui package

Add ui package in both native and web dependencies in package.json file

....
"ui" : "*",
....
Enter fullscreen mode Exit fullscreen mode

Replace apps/native/index.tsx in Expo app

import { Text } from "react-native";
import { View } from "ui/view";

export default function Index() {
  return (
    <View
      style={{
        flex: 1,
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <Text>Edit app/index.tsx to edit this screen.</Text>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Replace apps/web/index.tsx in Next.js app

"use client";

import { View } from "ui/view";

export default function Home() {
  return (
    <View
      style={{
        flex: 1,
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <p>
        Get started by editing&nbsp;
        <code className="font-mono font-bold">app/page.tsx</code>
      </p>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Configuring Metro bundler

To configure a monorepo with Metro manually, there are two main changes:

Wee need to make sure Metro is watching all relevant code within the monorepo, not just apps/native.

cd apps/native

npx expo customize metro.config.js
Enter fullscreen mode Exit fullscreen mode

Update metro.config.js

// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");

const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;

const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, "node_modules"),
  path.resolve(workspaceRoot, "node_modules")
];

config.resolver.disableHierarchicalLookup = true;

module.exports = config;
Enter fullscreen mode Exit fullscreen mode

Update default entry point for Expo app

Update main field in package.json in apps/native

- "main": "expo-router/entry",
+ "main": "index.js",
Enter fullscreen mode Exit fullscreen mode
import { registerRootComponent } from "expo";
import { ExpoRoot } from "expo-router";

// Must be exported or Fast Refresh won't update the context
export function App() {
  const ctx = require.context("./app");
  return <ExpoRoot context={ctx} />;
}

registerRootComponent(App);
Enter fullscreen mode Exit fullscreen mode

Now, we have a working native and web app using shared component with RNW

Results

All Platforms Screenshot

Universal Styling with NativeWind

Next, we want to further and style both platform using Tailwind in Next.js and on mobile, NativeWind is the right tool to achieve.

NativeWind allows you to use Tailwind CSS to style your components in React Native. Styled components can be shared between all React Native platforms.

cd apps/native

npx expo install nativewind@^4.0.1 react-native-reanimated tailwindcss
Enter fullscreen mode Exit fullscreen mode

Run pod-install to install Reanimated pod:

npx pod-install
Enter fullscreen mode Exit fullscreen mode

Run npx tailwindcss init to create a tailwind.config.js file

npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

Add the paths to all of your component files in your tailwind.config.js file.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
+   "./index.js",
+   "./app/**/*.{js,jsx,ts,tsx}",
+   "../../packages/**/*.{js,ts,jsx,tsx}"
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

Create a CSS file global.css and add the Tailwind directives

cd apps/native
touch global.css
Enter fullscreen mode Exit fullscreen mode

Copy & paste in global.css file

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Add babel preset

Configure babel to support NativeWind

module.exports = function (api) {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};
Enter fullscreen mode Exit fullscreen mode

Modify Metro Config

+const { withNativeWind } = require('nativewind/metro');
...
-module.exports = config;
+module.exports = withNativeWind(config, { input: './global.css' })
Enter fullscreen mode Exit fullscreen mode

Import your CSS file

In the app/layout.tsx file

import "../global.css";
....
Enter fullscreen mode Exit fullscreen mode

Typescript Support

NativeWind extends the React Native types via declaration merging. Add triple slash directive referencing the types. Add a file app-env.d.ts in apps/native root directory.

/// <reference types="nativewind/types" />
Enter fullscreen mode Exit fullscreen mode

Next.js Support

Update tailwind.config.js in apps/web

import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
+  important: "html",
+  presets: [require('nativewind/preset')],
  theme: {
    extend: {

    },
  },
  plugins: [],
};
export default config;
Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json file

{
  "compilerOptions": {
    "jsxImportSource": "nativewind"
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Nativewind in shared UI

In packages/ui

Update the view component we created earlier

import { View as ReactNativeView } from 'react-native'
+ import { cssInterop } from 'nativewind';

+ export const View = cssInterop(ReactNativeView, {
+    className: 'style',
+ });
Enter fullscreen mode Exit fullscreen mode

Finally add nativewind to list of packages to transpile

  transpilePackages: [
  ...,
+ "nativewind"
+ "react-native-css-interop"
 ]
Enter fullscreen mode Exit fullscreen mode

Update the use of the ui/view component in both web and mobile app

import { Text } from "react-native";
import { View } from "ui/view";

export default function Index() {
  return (
    <View
+      className="flex-1 justify-center items-center"'
-      style={{
-        flex: 1,
-        justifyContent: "center",
-        alignItems: "center",
-      }}>
...
Enter fullscreen mode Exit fullscreen mode

Now, Using className works just same as with web.

VSCode Intellisense Support

Create a new file tailwind.config.ts in the project root directory and paste the following in the file.

// Add file for tailwind intellisense. Leave this empty 
module.exports = {};
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

if you're using typescript, confirm you have the reference to NativeWind types in app-env.d.ts

/// <reference types="nativewind/types" />
Enter fullscreen mode Exit fullscreen mode

Happy to help with any issues, be sure to leave a comment if you need help or found this useful.

Links

Turborepo Monorepo Guide
Expo Documentation
NativeWind Setup Expo Router

Closing

Next, you'll need to build your own or custom universal components. I'll recommend React Native Reusables to get started with most basic components.

If you're interested in using an existing template, I've followed the steps from this guide to create a starter template on Github.

GitHub logo adebayoileri / universal-app-starter

Expo + Next.js (with React Native Web) template styled using TailwindCSS & NativeWind, featuring a shared component library for developing universal React applications.

Universal App Starter

Universal App Starter Screenshot

Get Started

Must have Node and Yarn(v1.22.19) installed to setup locally

yarn
Enter fullscreen mode Exit fullscreen mode

Development

yarn run dev
Enter fullscreen mode Exit fullscreen mode

Build

yarn run build
Enter fullscreen mode Exit fullscreen mode

Folder Structure

This monorepo consists of the two workspaces apps & packages

universal-app-starter
└── apps
    ├── native 
    └── web
└── packages
    ├── ui 
    └── app
Enter fullscreen mode Exit fullscreen mode

Apps and Packages

  • apps/native: a react-native app built with expo
  • apps/web: a Next.js app built with react-native-web
  • packages/ui: a shared package that contains shared UI components between web and native applications
  • packages/app: a shared package that contains shared logic between web and native applications

Technologies

Misc

Interested in setting up a similar project from scratch? Check out the article…






Universal App Starter

Top comments (4)

Collapse
 
mrobinson537 profile image
Matt Robinson

Thank you for the detailed walk through. It's nice to understand some of the reasoning behind each of the steps involved in setting this sort of repo up. Even though there are several example repo's around, they don't really tell you why things are set up the way they are so this was useful. Thanks!

Collapse
 
adebayoileri profile image
Adebayo Ilerioluwa

Glad you found it useful Matt.

Collapse
 
gaurav_verma_bba8b0dc9c6d profile image
GAURAV VERMA

thanks , the articles misses some step but the repo fixes it. this was what i needed

Collapse
 
adebayoileri profile image
Adebayo Ilerioluwa

Thanks for your feedback, Guarav.