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
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
Set private flag as true
+ "private": true
Note that the private: true is required, Workspaces are not meant to be published.
Create sub folders apps
and packages
"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.
yarn add turbo --dev
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
}
}
}
Update .gitignore
file
+ .turbo
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"
}
Setting up Packages
Create new shared packages for the monorepo in packages
folder containing
ui
app
cd into packages/ui
run
yarn init -y
Next, create an empty index.ts
in packages/ui file for now
Structure of Monorepo
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
cd apps
Setting up Nextjs app
Run
npx create-next-app@latest
Update tsconfig.json to include
"extends": "../../tsconfig.json",
cd apps/web
yarn run dev
Using Expo
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
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
{
"extends": "../../tsconfig.json",
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
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
To run your project, navigate to the directory and run one of the following commands.
cd native
- 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
"resolutions": {
"react": "18.2.0",
"react-native": "0.74.2",
"react-native-web": "~0.19.10",
"tailwindcss": "^3.4.1"
}
In apps/web
Run
yarn add react-native-web @expo/next-adapter
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;
}
});
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;
}
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;
Update the packages/ui/index.ts
Add
export {};
Using ui package
Add ui
package in both native and web dependencies in package.json file
....
"ui" : "*",
....
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>
);
}
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
<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
// 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
- "main": "expo-router/entry",
+ "main": "index.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
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
Run pod-install
to install Reanimated pod:
npx pod-install
Run npx tailwindcss init
to create a tailwind.config.js file
npx tailwindcss init
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: [],
}
Create a CSS file global.css
and add the Tailwind directives
cd apps/native
touch global.css
Copy & paste in global.css
file
@tailwind base;
@tailwind components;
@tailwind utilities;
Add babel preset
Configure babel to support NativeWind
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
Modify Metro Config
+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
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.
/// <reference types="nativewind/types" />
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;
Update tsconfig.json file
{
"compilerOptions": {
"jsxImportSource": "nativewind"
}
}
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',
+ });
Finally add nativewind
to list of packages to transpile
transpilePackages: [
...,
+ "nativewind"
+ "react-native-css-interop"
]
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",
- }}>
...
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 = {};
Troubleshooting
if you're using typescript, confirm you have the reference to NativeWind types in app-env.d.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
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.
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
Get Started
Must have Node and Yarn(v1.22.19) installed to setup locally
yarn
Development
yarn run dev
Build
yarn run build
Folder Structure
This monorepo consists of the two workspaces apps
& packages
universal-app-starter
└── apps
├── native
└── web
└── packages
├── ui
└── app
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 betweenweb
andnative
applications -
packages/app
: a shared package that contains shared logic betweenweb
andnative
applications
Technologies
- Expo for native development
- Next.js for web development
- React Native for native development
- React Native Web for web development
- NativeWind styling solution for native
- TypeScript for static type checking
- Prettier for code formatting
- Turborepo build system for managing monorepo
Misc
Interested in setting up a similar project from scratch? Check out the article…
Universal App Starter
Top comments (4)
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!
Glad you found it useful Matt.
thanks , the articles misses some step but the repo fixes it. this was what i needed
Thanks for your feedback, Guarav.