Setup Your Universal App with React Native, Expo Router, Tamagui, and Storybook

·

12 min read

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

  1. Node.js Latest Version: Ensure you have Node.js installed on your system.
  2. For Android:
    • Android Studio
    • Emulator
  3. 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:

  1. Create a file called declaration.d.ts in your root directory
  2. 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