Overview
In this guide, we'll walk you through setting up a universal app using React Native, Expo Router, Tamagui, and Storybook. This tutorial will provide step-by-step instructions to help you kickstart your universal app development journey.
Prerequisites
- Node.js Latest Version: Ensure you have Node.js installed on your system.
-
For Android:
- Android Studio
- Emulator
-
For macOS:
- Xcode
- Simulator
Expo Router Setup
First, we will install an Expo project with file-based navigation using the Expo router.
Open your terminal from any preferred directory, then enter the command.
npx create-expo-app@latest --template tabs@50
Now, you can start your project by running:
npx expo start
This will create a minimal project with the Expo Router library already installed. This approach is recommended by Expo's official documentation, making setup easier compared to manual installation, which involves several steps. It's better to install it this way and then clean up the project. Next, we'll install Tamagui into our project.
Tamagui Setup
Step 01:
First we have to install @tamagui/babel-plugin
package by this command
npm install @tamagui/babel-plugin
Step 02:
We have updated our babel.config.js
file to include the optional @tamagui/babel-plugin
. You can find the babel.config.js
file in the root directory of your Expo project. Simply copy the code provided and paste it into your babel.config.js
file.
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"@tamagui/babel-plugin",
{
components: ["tamagui"],
config: "./tamagui.config.ts",
logTimings: true,
disableExtraction: process.env.NODE_ENV === "development",
},
],
// NOTE: this is only necessary if you are using reanimated for animations
"react-native-reanimated/plugin",
],
};
};
Step 03:
Because we are setting up a universal app, we need to configure something specific for the web, especially for Tamagui. Therefore, we will make some adjustments to our metro.config.js
file. You won't find this file in the root directory, so you'll need to create it manually with exactly the same name.
Step 04:
Now install some dependency packages for web support using this command:
npm install tamagui @tamagui/config @tamagui/metro-plugin
With this command, we are actually installing the Tamagui component library, Tamagui configuration, and the Metro plugin for web support.
Step 05:
In your metro.config.js
file that you have created previously, add the following code. Later, we will modify this file for Storybook, so remember that.
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
// [Web-only]: Enables CSS support in Metro.
isCSSEnabled: true,
});
// add nice web support with optimizing compiler + CSS extraction
const { withTamagui } = require("@tamagui/metro-plugin");
module.exports = withTamagui(config, {
components: ["tamagui"],
config: "./tamagui.config.ts",
outputCSS: "./tamagui-web.css",
});
Step 06:
Now, create another file in your root directory called tamagui.config.ts
. In this file, we will configure our Tamagui settings.
Step 07:
Now add this following code into the file tamagui.config.ts
import { config } from "@tamagui/config/v3";
import { createTamagui } from "tamagui";
export const tamaguiConfig = createTamagui(config);
export default tamaguiConfig;
export type Conf = typeof tamaguiConfig;
declare module "tamagui" {
interface TamaguiCustomConfig extends Conf {}
}
So here, we are importing the Tamagui configuration v3 and exporting it as is, while also declaring the types. With this, our default Tamagui configuration setup is complete. Now, let’s utilize it.
Step 08:
So, you will find a folder named ./app
where all of the Expo code will be available. Inside this folder, navigate to the _layout.tsx
file and replace the code at the top with the following.
import { Platform } from "react-native";
if (Platform.OS === "web") {
import("../tamagui-web.css");
}
import {
useFonts,
Inter_400Regular,
Inter_900Black,
} from "@expo-google-fonts/inter";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { useColorScheme } from "@/components/useColorScheme";
import { TamaguiProvider } from "tamagui";
import tamaguiConfig from "@/tamagui.config";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from "expo-router";
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: "(tabs)",
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
Inter: Inter_400Regular,
InterBold: Inter_900Black,
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<TamaguiProvider
config={tamaguiConfig}
defaultTheme={colorScheme as string}
>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
</Stack>
</ThemeProvider>
</TamaguiProvider>
);
}
Here we add the Tamagui provider to wrap our entire app with Tamagui using the default configuration. Later, we will modify the configuration. We import the CSS specifically for the web. You can see the code at the top of the file where we added a condition for importing the CSS. This means that the CSS will only be imported when the platform is web.
Step 09:
We added the Inter font from @expo-google-fonts/inter
into our _layout.tsx
file using the useFonts()
hook. This font is used in the CSS file we imported. So, installing this font is necessary. Install it with this command.
npm install @expo-google-fonts/inter
You're done! However, if you run the project, you may encounter some errors and issues. I faced those and fixed them. The Tamagui documentation doesn't mention anything like that, so you'll have to do a little additional work.
Step 10:
So, in your _layout.tsx
file, you may encounter errors if you've installed ESLint as a VS Code extension. Then, your import ("../tamagui-web.css")
code will give you an error like "don't have any type declaration." To get rid of this error, follow these steps:
- Create a file called
declaration.d.ts
in your root directory - Then go to your
tsconfig.json
file from the root directory and replace your code with this one:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"esModuleInterop": true,
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
So now you will be able to get rid of the warning.
Step 11:
Replace your package.json
file's script object with this.
"scripts": {
"start": "expo start -c",
"android": "expo start --android -c",
"ios": "expo start --ios -c",
"web": "expo start --web -c",
"test": "jest --watchAll"
}
Here we are actually starting our project by cleaning the Expo cache, which is done by adding -c
to our command.
Step 12:
Run the app
for running all platforms: npm start
and then press w
for web, press i
for ios and press a
for Android. Make sure you have an Android emulator or code simulator
So if you see any error occurring after running the app. Then don’t worry. Just close the terminal and restart the command like npm start
or platform-specific we declare on the package.json
file.
For seeing that tamagui working or not. Go to your (tabs)
folder inside your app
directory. And goto index.tsx
file and replace the code with this one. Make sure you install @tamagui/lucide-icons
by npm install @tamagui/lucide-icons
import { Airplay } from "@tamagui/lucide-icons";
import { Button, Heading, View } from "tamagui";
export default function TabOneScreen() {
return (
<View flex={1} alignItems="center" justifyContent="center">
<Heading size={"$10"} color={"violet"}>
Tab One
</Heading>
<Button
color={"violet"}
backgroundColor={"black"}
alignSelf="center"
icon={Airplay}
mt={10}
size="$6"
>
Button
</Button>
</View>
);
}
Here we are simply replacing the old View
, Text
, and Button
components from React Native with their Tamagui counterparts. Now, you can leverage Tamagui along with React Native Expo Router. If you don't require Storybook, you can skip the next two sections.
Storybook Setup
Now, we will set up Storybook in our Expo project and configure scripts to run the project with or without Storybook. Let's get started. Although there are templates available on their official GitHub repository, since we have already set up our project, we will need to add Storybook manually.
Step 01:
Run init to setup your project with all the dependencies and configuration files:
npx storybook@latest init
Now, you will notice a folder called .storybook
. Do not delete it, as it contains all of our Storybook configurations. You can run Storybook simply by exporting it from your _layout.tsx
file like this: export { default } from './.storybook
'. However, we want to run it with a script to include Storybook in our project or exclude it. Therefore, we need to perform some manual configurations.
Step 02:
Now Replace your metro.config.js
file we created and modified for tamagui. Replace all of the code by this.
const { getDefaultConfig } = require("expo/metro-config");
const { withTamagui } = require("@tamagui/metro-plugin");
/** @type {import('expo/metro-config').MetroConfig} */
// Storybook config
const path = require("path");
const { generate } = require("@storybook/react-native/scripts/generate");
generate({
configPath: path.resolve(__dirname, "./.storybook"),
});
// Tamagui Config
const config = getDefaultConfig(__dirname, {
isCSSEnabled: true,
});
// Storybook config
config.transformer.unstable_allowRequireContext = true;
config.resolver.sourceExts.push("mjs");
// Export the config with tamagui
module.exports = withTamagui(config, {
components: ["tamagui"],
config: "./tamagui.config.ts",
outputCSS: "./tamagui-web.css",
});
Step 03:
So we are going to use the expo-constants
package to enable Storybook integration. Install the package by running the following command:
npm install expo-constants
Step 04:
Rename your app.json
file to app.config.js
and export the object as follows:
module.exports = {
...all the property from your app.json file [Means take the json file object and paste it here]
}
Step 05:
Now add an extra
object with a property to control the value for running the project with Storybook or without it. Here's it will be:
module.exports = {
...,
"extra": {
"storybookEnabled": false
}
}
This configuration allows you to run the project with Storybook by setting the storybookEnabled
variable to true. If it's not set or set to any other value, Storybook will not be included.
Step 06:
So now how we can do that? We will write a little bit node js script to modify our app.config.js
file. So let’s create e file called update-config.js
and paste this code:
const fs = require("fs");
const path = require("path");
const configFilePath = path.join(__dirname, "app.config.js");
const configContent = fs.readFileSync(configFilePath, "utf-8");
const newValue = process.argv[2] === "true"; // Get the desired value from the command-line argument
const updatedContent = configContent.replace(
/storybookEnabled:\s*(true|false)/,
`storybookEnabled: ${newValue}`
);
if (updatedContent !== configContent) {
fs.writeFileSync(configFilePath, updatedContent);
console.log(`app.config.js updated with storybookEnabled set to ${newValue}`);
} else {
console.log(`app.config.js already has storybookEnabled set to ${newValue}`);
}
If you don’t understand this code, no worries, you can ignore it. Essentially, it reads the app.config.js
file and replaces the storybookEnabled
value with the provided value when we run the script. It will change it to true
or false
based on our command.
Step 07:
Now time to change our package.json
file script command to do everything by command not manually. So replace your script object with this one into your package.json
file:
{
"start": "node update-config.js false && expo start -c",
"android": "node update-config.js false && expo start --android -c",
"ios": "node update-config.js false && expo start --ios -c",
"web": "node update-config.js false && expo start --web -c",
"test": "jest --watchAll",
"storybook-generate": "sb-rn-get-stories",
"storybook:start": "node update-config.js true && expo start -c",
"storybook:android": "node update-config.js true && expo start --android -c",
"storybook:ios": "node update-config.js true && expo start --ios -c",
"storybook:web": "node update-config.js true && expo start --web -c"
}
Make sure you replace all the code inside the script object.
If you run it now, your project should run without giving any errors. However, we haven't iterated on the Storybook setup yet. Before that, let’s check if everything is working fine. Run the project using npm start. It may show some warnings like this:
The following packages should be updated for best compatibility with the installed expo version:
package name
package name
package name
Your project may not work correctly until you install the correct versions of the packages
That means you have to install those specific packages to run the application properly. Make sure you install all the packages mentioned with the exact version numbers specified. This warning may appear because your Expo project version is higher than the versions of the dependencies used by Storybook. After installing those packages, run the project again to check if it builds successfully.
Now, let’s add the code to swap between running our app with Storybook and without it.
Step 08:
Go to the ./app
folder and create a file called storybook.tsx
. This file will render the Storybook on a specific route. Our plan is that if we enable Storybook by setting the storybookEnabled
flag to true and run the Storybook initialization command, then the route should render Storybook. If we don't enable Storybook, it will redirect to the home page. Inside this file, add the following code:
import { Redirect } from "expo-router";
import StorybookUIRoot from "../.storybook";
import Constants from "expo-constants";
export const isStoryBookEnabled =
Boolean(Constants?.expoConfig?.extra?.storybookEnabled) === true;
export default function StorybookScreen() {
if (isStoryBookEnabled) {
return <StorybookUIRoot />;
}
return <Redirect href="/" />;
}
So here, we are accessing the storybookEnabled
variable from the extra object using expo-constants and checking whether it's set to true or not. If it's true, then we will render the Storybook. Now, we are almost done! To run the project with Storybook, use this command:npm run storybook:start
. You may encounter an error similar to this:
Unknown named module: "@storybook/global"
...bla bla
To resolve this error, you need to remove an addon from the Storybook plugin. Navigate to your .storybook
folder and open the main.ts file. Replace the existing code with the following:
import { StorybookConfig } from "@storybook/react-native";
const main: StorybookConfig = {
stories: ["./stories/**/*.stories.?(ts|tsx|js|jsx)"],
addons: ["@storybook/addon-ondevice-controls"],
};
export default main;
Here we just removed '@storybook/addon-ondevice-actions’
this plugin from the storybook config. Now run the project again with npm run storybook:start
And the error should gone!
Now let’s change a little bit on our./(tabs)/index.tsx
file with following code:
import { Airplay } from "@tamagui/lucide-icons";
import { Button, Heading, View } from "tamagui";
import { router } from "expo-router";
import { isStoryBookEnabled } from "../storybook";
export default function TabOneScreen() {
return (
<View flex={1} alignItems="center" justifyContent="center">
<Heading size={"$10"} color={"violet"}>
Tab One
</Heading>
<Button
color={"violet"}
backgroundColor={"black"}
alignSelf="center"
icon={Airplay}
mt={10}
size="$6"
>
Button
</Button>
{isStoryBookEnabled ? (
<Button
color={"violet"}
onPress={() => {
router.replace("/storybook");
}}
backgroundColor={"black"}
alignSelf="center"
icon={Airplay}
mt={10}
size="$6"
>
Go to Storybook
</Button>
) : null}
</View>
);
}
Here we are checking whether our Storybook is enabled or not. Based on that, we have added a Storybook button.
Now you can test both scenarios:
With Storybook:
npm run storybook:start
Without Storybook:
npm start
So if you don’t want Storybook to show, simply run the command without Storybook enabled! For the production bundle, ensure that you remove Storybook from the bundler. You can refer to this guide to remove Storybook from the bundle Check it out
Feel free to reach out if you need more insights. Thank you for taking the time to read this article. Happy coding!
Written with 🧡 by Hasan
Top comments (1)
Thank you for sharing this comprehensive guide on setting up a universal app using React Native, Expo Router, Tamagui, and Storybook. It's an invaluable resource for developers looking to streamline their cross-platform development process.
I’d like to provide some additional context on Tamagui, a key component in this setup. Tamagui is an innovative framework that unifies cross-platform UI development, addressing many challenges developers face in creating consistent, high-performance user interfaces across multiple platforms.
Key features of Tamagui include:
tamagui
package provides a rich set of customizable UI components, designed to work seamlessly across platforms.@tamagui/core
package forms the foundation for styling and component creation, allowing for consistent design across web and mobile.Tamagui is designed to reduce development time and eliminate code duplication, common challenges in cross-platform development. Its ecosystem—comprising
@tamagui/core
,@tamagui/static
, andtamagui
—provides developers with a robust toolkit for building visually consistent and high-performance apps.Compared to frameworks like Flutter, Ionic, and React Native libraries such as React Native Paper and NativeBase, Tamagui sets itself apart with its strong emphasis on performance optimization and its seamless functionality across both React Native and web environments.
That said, it’s important to note that Tamagui is a relatively new framework with a smaller user base. This may result in fewer third-party resources and support for Tamagui-specific issues compared to more established frameworks.
For a deeper dive into Tamagui and its potential impact on cross-platform UI development, I recommend reading this detailed article by my colleague, Mohammed Sohail. His piece offers an in-depth look at Tamagui’s features, ecosystem, and how it compares to other cross-platform development solutions.
You can find Mohammed’s article here: Tamagui Overview.