Using Storybook with Expo Router v2, SDK 49 & TypeScript

Search for a command to run...

I really enjoyed the article, and it was very helpful in getting started with setting up Storybook in my Expo project.
Now, as I'm trying to create a production build, I have a new question. How can we ensure that the production build doesn't include Storybook code?
Switching the real and Storybook application conditionally inside the code doesn't prevent Metro from bundling Storybook code.
I'm currently exploring a solution to this issue, but if you have any suggestions, I would greatly appreciate it if you could share your thoughts.
Thanks! Yes! In fact I already published another post covering how to setup storybook web and mobile in the same repo.
https://blog.spirokit.com/setting-up-storybook-web-and-native-with-expo-router-v2-sdk-49-and-typescript
Thanks for the guide!
I recently created a new project with expo-router and I was confused with the app/ folder
One suggestion tho
my launch script looks like this
"storybook": "sb-rn-get-stories && EXPO_PUBLIC_STORYBOOK_ENABLED='true' expo start",
and the index.tsx page
const storybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED;
I don't have .env file, no need to manually edit, it's faster to swap between the app or the storybook with
yarn start
yarn storybook
Glad to help, David! And thanks for your comment about the alternative. I implemented the .env file that way for 2 main reasons: 1 - It works on any OS, including Windows 2 - I wanted to experiment with the new Expo env variables support
Adding the env flags directly in the package.json may be convenient, but I'll need to use cross-env or something similar so it can work on Windows.
Thanks Joe! Glad to hear that 🙌 Happy to connect 🙂 I’m already working on the next article that adds Storybook web in the same repo.
💡 This is the third part of the "Collecting payments" series. Before proceeding, make sure to read Part 1, Part 2 & Part 3 We've finally reached the last part of the series! With all the tedious aspects taken care of, we just need to connect everyt...

💡 This is the third part of the "Collecting payments" series. Before proceeding, make sure to read Part 1 and Part 2 Before diving into this third part, I highly recommend watching this video to become familiar with some essential RevenueCat conce...

💡 This is the third part of the "Collecting payments" series. Before proceeding, make sure to read Part 1 SpiroKit for SaaS In case you want to save weeks of painful hours of work & research on your next React Native app, checkout SpiroKit for Sa...

Introduction Creating a mobile app involves numerous challenges and time-consuming tasks that must be completed before we can release our app in the stores. In-app purchases and subscriptions are especially difficult examples of this. They require co...

Building apps take time Building a mobile app is a challenging task. From coming up with an idea, prototyping, and finally building the app, there are many things to consider and problems we'll have to figure out: How do we make sure the entire app ...

Imagine having a web portal to showcase, document, test, and improve all your React Native components. A place where you can build your own library over time and then use that library to quickly build and release all those app ideas you have.
If that sounds appealing to you, you'll love Storybook.
In this post, I'll talk about what Storybook is, how it can help you, and how to add it to your Expo project, taking advantage of Expo SDK 49 new features and Expo Router v2.
At the end of the post, you’ll have a working repo with Expo SDK 49, Storybook for React Native, Expo Router v2, TypeScript, and more!
If you already know Storybook, and you just want a working repo to fork, here is the link to the Github repo
Storybook is an open-source tool for building UI components and pages in isolation. In other words, it's a library you can add to your project to test and document your components.
Are you still confused? No worries. I had to read the official documentation and search for real-life examples before genuinely understanding how powerful this tool is. So, follow me with this basic example.
Let's say we have a simple "Button" component. It receives the following props:
If you are working for a company, chances are that you'll have to communicate how your new component works to the rest of the team.
Besides, your teammates must know that your Button component exists to avoid code duplication.
Last but not least, you will need to test your component with different props combinations to make sure everything works as expected. For example, what happens if we use a really long text for our button? Is our component responsive?
Storybook is the solution to all the problems I mentioned before. It helps you build a centralized component library with rich and interactive documentation so your teammates can reuse or even improve it.
There are two main ways to use Storybook with Expo. Based on what you choose, the installation process will be different.
react starterreact-native starterThis article will be focused on showing how to run Storybook on a native device, but let me know if you are interested in learning how to build and deploy a web interface to share with the rest of the team. Maybe I could write about it in the next post.
As of the writing of this article (July, 2023), I couldn’t find an official expo template that comes with TypeScript and Expo Router v2 configured, so let’s start by using the vanilla JS Expo Router starter and add TypeScript later.
Run the following command to create an empty project with Expo Router configured.
npx create-expo-app@latest -e with-router
Because this starter comes with Expo SDK 48, we’ll need to run the following command to update Expo to SDK 49
expo upgrade
Make sure to check if the starter already comes with SDK 49. Maybe it’s not necessary to do the upgrade anymore (The Expo team moves really fast)
You may need to install the
expo-cliglobally before running the upgrade. If that’s the case, runnpm i -g expo-cli
Based on Expo official docs, for Expo Router v2 is no longer necessary to include the resolutions and overrides sections in the package.json, so we can delete them like this:
{
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
...
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@babel/plugin-proposal-export-namespace-from": "^7.18.9"
},
-"resolutions": {
- "metro": "^0.73.7",
- "metro-resolver": "^0.73.7"
-},
-"overrides": {
- "metro": "^0.73.7",
- "metro-resolver": "^0.73.7"
-},
"name": "expo-router-storybook-starter",
"version": "1.0.0",
"private": true
}
Run the following commands to create an empty src folder where our components will be added, and an app folder for our application routes.
mkdir src
mkdir app
Create an empty tsconfig.json by running this command
touch tsconfig.json
With the tsconfig.json created, you can run npx expo start. Expo will detect a tsconfig.json file, ask you to install the missing dependencies, and handle the required setup for you, inheriting a few defaults from Expo’s base config.
package.json to move both typescript and @types/react dependencies under devDependencies. Expo SDK 49 introduces support for Path Aliases. You can optionally follow these steps to setup a path alias for the src directory
Update your app.json by adding the “experiments” section like this:
{
"expo": {
"scheme": "acme",
"web": {
"bundler": "metro"
},
"name": "expo-router-storybook-starter",
"slug": "expo-router-storybook-starter",
+ "experiments": {
+ "tsconfigPaths": true
+ }
}
}
Update the tsconfig.json to apply this configuration to our src directory.
{
"compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
},
"extends": "expo/tsconfig.base"
}
That’s it! Now you can import your components like this:
import Button from '@/components/Button';
Instead of doing this:
import Button from '../../components/Button'
Thanks to the amazing Storybook CLI, adding Storybook to your project is as simple as executing the following command
npx storybook@latest init
A few things I would like to mention about this process:
.storybook that will include all the initial configs so you can run Storybook with the essential add-ons activated.During setup, Storybook will install a few additional dependencies that, at the time of this writing, will throw a warning when you run npx expo start due to a mismatch between the installed versions and the ones expected by Expo. I’m talking about @react-native-async-storage/async-storage and @react-native-community/datetimepicker
Because I want to keep these versions, I decided to take advantage of a new feature released with SDK 49, which allows you to opt out from package version validations for a list of dependencies.
Feel free to run
expo doctor --fixif you want to downgrade your dependencies to the version expected by Expo.
To use this new feature, we need to update the package.json, adding an array with the dependencies we want to exclude from the validation.
{
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"storybook-generate": "sb-rn-get-stories",
"storybook-watch": "sb-rn-watcher"
},
"dependencies": {
...
},
"devDependencies": {
...
},
+ "expo": {
+ "install": {
+ "exclude": [
+ "@react-native-async-storage/async-storage",
+ "@react-native-community/datetimepicker"
+ ]
+ }
+ },
"name": "expo-router-storybook-starter",
"version": "1.0.0",
"private": true
}
You can now run npm start and verify you no longer see the previous warnings
I wanted to use an environment variable to enable/disable the Storybook UI based on my needs during development, so I decided to try another new feature included in SDK 49: Client environment variables.
If you want to learn more about Environment variables in Expo, checkout the amazing official docs by the Expo team. They even included steps to migrate from different tools like
direnvorreact-native-config🙌
When you use client environment variables, you can simply create an .env file, and add your keys using the EXPO_PUBLIC_ prefix, and Expo will automatically expose those keys through Metro. That means we no longer need the transform-inline-environment-variables babel plugin.
Remove the mentioned plugin from babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
- "transform-inline-environment-variables",
"react-native-reanimated/plugin",
require.resolve("expo-router/babel"),
],
};
};
Create an empty .env file
touch .env
Update the new .env file, adding the following environment variable
EXPO_PUBLIC_STORYBOOK_ENABLED=true
Because we are using Expo Router, we need to create a layout route. On this route, we’ll simply export a React component with a Slot, so Expo Router can render the child route there.
touch app/_layout.tsx
// app/_layout.tsx
import { Slot } from "expo-router";
let RootApp = () => {
return <Slot />;
};
export default RootApp;
Then, add a new Index.tsx file inside the app directory. In this file, we’ll check the value of the environment variable to decide what to render
touch app/Index.tsx
// app/Index.tsx
import React from "react";
import { Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const storybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true";
const Index = () => {
return (
<SafeAreaView>
<Text>Hello world</Text>
</SafeAreaView>
);
};
let EntryPoint = Index;
if (storybookEnabled) {
const StorybookUI = require("../.storybook").default;
EntryPoint = () => {
return (
<View style={{ flex: 1 }}>
<StorybookUI />
</View>
);
};
}
export default EntryPoint;
Based on Storybook docs, we also need to customize our metro.config.js to use the sbmodern resolver field in order to resolve the modern version of storybook packages. Doing this removes the polyfills that ship in commonjs modules and fixes multiple long-standing issues such as the promises never resolving bug and more (caused by corejs promises polyfill).
Create a new metro.config.js file
touch metro.config.js
Use the following configuration
const { getDefaultConfig } = require("expo/metro-config");
module.exports = (async () => {
let defaultConfig = await getDefaultConfig(__dirname);
defaultConfig.resolver.resolverMainFields.unshift("sbmodern");
return defaultConfig;
})();
npm startWe’ll need to update our start script so Storybook can load the stories before the app starts.
{
"scripts": {
- "start": "expo start",
+ "start": "sb-rn-get-stories && expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"storybook-generate": "sb-rn-get-stories",
"storybook-watch": "sb-rn-watcher"
},
...
}
With everything in place, we should be able to run our app and see the Storybook UI like this:

If you now go to your .env file and set EXPO_PUBLIC_STORYBOOK_ENABLED to false, you can hit reload on your running app, and instantly see how your app loads instead of the Storybook UI 🪄
After running your app for the first time with Storybook, you should see a new file that was automatically generated by Storybook. I’m talking about the
storybook.requires.jsfile. Please avoid changing this file.
We could stop here, but I wanted to add a few stories using TypeScript, so everything is set up correctly. Let’s work on that!
One thing I don’t like about the default Storybook setup, is that it adds two example files: .storybook/stories/Button.js and .storybook/stories/Button.stories.js. If you are new to React Native and to Storybook, you may think it’s a good idea to store all your components directly in the .storybook/stories directory. Instead, I want to create a Button.tsx component inside src/components, and then import that component into my Storybook story.
Create the new components folder inside the src directory
mkdir src/components
Move the Button.js file into the new directory, and rename it so you can use TypeScript
mv .storybook/stories/Button/Button.js src/components/Button.tsx
Move Button.stories.js to the .storybook/stories directory, and rename it to use TypeScript
mv .storybook/stories/Button/Button.stories.js .storybook/stories/Button.stories.tsx
Remove the Button directory, as it is no longer needed
rm -rf .storybook/stories/Button
Rename a few more files to use TypeScript
mv .storybook/main.js .storybook/main.ts
mv .storybook/preview.js .storybook/preview.ts
mv .storybook/index.js .storybook/index.ts
Update the Button.tsx component to add a type for the props, and also add an additional flag for the disabled state
// src/components/Button.tsx
import React from "react";
import { TouchableOpacity, Text, StyleSheet } from "react-native";
export type MyButtonProps = {
onPress?: () => void;
text: string;
disabled?: boolean;
};
export const MyButton: React.FC<MyButtonProps> = ({
onPress,
text,
disabled
}) => {
return (
<TouchableOpacity
style={[styles.container, { opacity: disabled ? 0.3 : 1 }]}
onPress={onPress}
activeOpacity={0.8}
disabled={disabled}
>
<Text style={styles.text}>
{text}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: "purple",
borderRadius: 8,
},
text: { color: "white" },
});
Update the Button.stories.tsx file to reference the Button component from the new location, and we’ll also use a few types from Storybook to get proper IntelliSense for the args on each story.
import React from "react";
import { View } from "react-native";
import { MyButton, MyButtonProps } from "../../src/components/Button";
import { Meta, StoryObj } from "@storybook/react-native";
const meta: Meta<MyButtonProps> = {
title: "Button",
component: MyButton,
argTypes: {
onPress: {
action: "onPress event",
},
},
decorators: [
(Story) => (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Story />
</View>
),
],
};
export default meta;
type Story = StoryObj<MyButtonProps>;
export const Basic: Story = {
storyName: "Basic",
args: {
disabled: false,
text: "Tap me",
},
};
export const Disabled: Story = {
args: {
disabled: true,
text: "Disabled",
},
};
A few things to mention here:
Meta type from Storybook to get better intelliSense for args and argTypes.You can use the
argTypesobject insidemetato describe what control you want for each arg. If you want to learn more about available controls, checkout this link
I hope you find this post valuable! I would like to mention that the starter React Native template from Storybook was an amazing starting point. I just wanted to go the extra mile to set up everything with SDK 49, Expo Router, and TypeScript.
I would also like to thank:
Please let me know if I’m missing something, or if I’m making a silly mistake in the process. I’m here to learn and improve.
Happy coding.