Building a universal React app with Expo, NextJS & Nativewind
Tags: react, javascript, mobile, webdev
Published: 2024-07-02 10:34:56

## 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](https://docs.expo.dev/get-started/introduction/)
> Expo is a framework that makes developing Android and iOS apps easier

#### [Next.js](https://nextjs.org/docs)
> Next.js is a React framework for building full-stack web applications.

#### [React Native for Web](https://necolas.github.io/react-native-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

```bash
yarn init
```

Using Classic yarn as [Expo documentation](https://docs.expo.dev/guides/monorepos/) 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.

```bash
yarn set version 1.22.19
```

Set private flag as true 

```diff
+ "private": true
```

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

Create sub folders `apps` and `packages`

```json
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
```

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

```bash
yarn add turbo --dev
```

Add `turbo.json` file

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

Update `.gitignore` file

```diff
+ .turbo
```


Setup Default Typescript config in the root workspace

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

## Setting up Packages
Create new shared packages for the monorepo in `packages` folder containing

`ui`
`app`

cd into `packages/ui`

run 

```bash
yarn init -y
```

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


## Structure of Monorepo

```bash
universal-app-starter
└── apps
    ├── native 
    └── web
└── packages
    ├── ui 
    └── app
```

### Setup default `apps` for native and web with Expo & Next.js
Navigate to the apps directory

```bash
cd apps
```

## Setting up Nextjs app
Run 

```bash
npx create-next-app@latest
```

![Selection for create next app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5cju04hvrcyyu86bibb5.png)

Update tsconfig.json to include

```json
  "extends": "../../tsconfig.json",
```

```bash
cd apps/web

yarn run dev
```

## Using Expo
```bash
npx create-expo-app@latest
```

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

```bash
yarn run reset-project
```

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

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

Ensure you have `expo-env.d.ts` file

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

// NOTE: This file should not be edited and should be in your git ignore
```

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

```bash
cd native
```

```bash
- yarn run android
- yarn run ios
- yarn run web
```

### Setting Up React Native Web in Next.js app
In the root directory, add resolutions to package.json file

```json
  "resolutions": {
    "react": "18.2.0",
    "react-native": "0.74.2",
    "react-native-web": "~0.19.10",
    "tailwindcss": "^3.4.1"
  }
```

In **apps/web**
Run

```bash
yarn add react-native-web @expo/next-adapter
```

### Updating Next.js Configuration

Edit **next.config.js**

```ts
/** @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;
  }
});
```
### 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`
```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;
}
```

### 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

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

export const View = ReactNativeView;
```

Update the `packages/ui/index.ts`

Add 

```ts
export {};
```

### Using *ui* package

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

```json
....
"ui" : "*",
....
```

Replace `apps/native/index.tsx` in Expo app

```jsx
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>
  );
}
```

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

```jsx
"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>
  );
}
```

### 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
```
Update *metro.config.js*

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

### Update default entry point for Expo app

Update main field in package.json in `apps/native`

```diff
- "main": "expo-router/entry",
+ "main": "index.js",
```

```js
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);
```

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

## Results
![All Platforms Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r2u9amxrok84rjee4n3a.png)

### 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](https://www.nativewind.dev/v4/overview) allows you to use Tailwind CSS to style your components in React Native. Styled components can be shared between all React Native platforms.


```bash
cd apps/native

npx expo install nativewind@^4.0.1 react-native-reanimated tailwindcss
```

Run `pod-install` to install Reanimated pod:

```bash
npx pod-install
```

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

```bash
npx tailwindcss init
```

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

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

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

```bash
cd apps/native
touch global.css
```

#### Copy & paste in `global.css` file

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

#### Add babel preset
Configure babel to support NativeWind

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

### Modify Metro Config

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

### Import your CSS file

In the `app/layout.tsx` file

```tsx
import "../global.css";
....
```

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

```ts
/// <reference types="nativewind/types" />
```

### Next.js Support
Update tailwind.config.js in `apps/web`
```js
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;
```

Update tsconfig.json file
```json
{
  "compilerOptions": {
    "jsxImportSource": "nativewind"
  }
}
```

## Using Nativewind in shared UI
In `packages/ui` 

Update the `view` component we created earlier

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

+ export const View = cssInterop(ReactNativeView, {
+    className: 'style',
+ });
```

Finally add `nativewind` to list of packages to transpile

```diff
  transpilePackages: [
  ...,
+ "nativewind"
+ "react-native-css-interop"
 ]
```

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

```diff
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",
-      }}>
...
```

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.

```ts
// Add file for tailwind intellisense. Leave this empty 
module.exports = {};
```

## Troubleshooting
- [Troubleshooting NativeWind](https://www.nativewind.dev/v4/guides/troubleshooting)
- [NativeWind Next.js Common Issues](https://www.nativewind.dev/v4/getting-started/nextjs#common-issues)

if you're using typescript, confirm you have the reference to NativeWind types in `app-env.d.ts`
```ts
/// <reference types="nativewind/types" />
```

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

## Links
[Turborepo Monorepo Guide](https://turbo.build/repo/docs/crafting-your-repository/structuring-a-repository)
[Expo Documentation](https://docs.expo.dev/)
[NativeWind Setup Expo Router](https://www.nativewind.dev/v4/getting-started/expo-router)

## Closing
Next, you'll need to build your own or custom universal components. I'll recommend [React Native Reusables](https://rnr-docs.vercel.app/getting-started/introduction/) 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.
{% embed https://github.com/adebayoileri/universal-app-starter %}
[Universal App Starter](https://github.com/adebayoileri/universal-app-starter)