Building beautiful components faster with Storybook 9 and Expo
Development•React Native••12 minutes read
Daniel Williams
Guest Author
Learn how to set up Storybook 9 in your Expo app to build, test, and share React Native UI components faster and with less friction.

This is a guest blog post from Daniel Williams - a frontend developer and open-source maintainer with over 10 years of experience in React and React Native.
...
Picture this: You’re building a complex screen deep in your React Native app with a variety of success and error states. Whenever one of your changes triggers a full reload, you need to login to your app and get back to the state you were working on.
The first few times this happens, you shrug it off. But this workflow gets tiresome really fast!
What if, instead, you had a catalog of each of your UI states? You could not only navigate directly to that screen to pick up where you left off before the change, but you could also share those screens with your teammates for easy review. You could even turn those UI states into automated tests that run quickly in CI without all the flake of an end-to-end test.
The workflow I’m describing here is exactly what Storybook for React Native is for. In this post, I’ll guide you how to get it set up in your Expo app so you can get the most out of it. The post is based on the recently released Storybook 9, which offers the best React Native support yet.
Setup Storybook 9 in an Expo app
For those new to Storybook, it's a development environment that lets you build, test, and document UI components in isolation. Think of it as a workshop where you can craft each component independently, without the complexity of your full application. This component-first workflow enables visual testing, serves as living documentation, and helps teams maintain design consistency across large codebases.
Before we begin, make sure you have:
- An existing Expo app using Expo Router (SDK 50 or later recommended)
- Or create a new one to follow along with
npx create-expo-app - if you make a new expo app, run
npm run reset-projectto get a clean project
- Or create a new one to follow along with
- Node.js 20+ and your preferred package manager (bun, npm, yarn, or pnpm)
- A device or simulator for testing
If you want to check the finished version of the storybook in this guide you can find the example repo here.
Installing Storybook
The easiest way to add Storybook to your existing Expo Router project is the create command
- npm create storybook@latestWhen prompted, choose recommended and then React Native. This creates a .rnstorybook configuration folder and installs the necessary dependencies, including @storybook/react-native.
Configure Metro bundler
We'll need some Metro configuration to work with Storybook. First, customize your Metro config:
npx expo@latest customize metro.config.js
Then update your metro.config.js to include Storybook support:
// Learn more https://docs.expo.io/guides/customizing-metroconst { getDefaultConfig } = require("expo/metro-config");/** @type {import('expo/metro-config').MetroConfig} */const config = getDefaultConfig(__dirname);const withStorybook = require("@storybook/react-native/metro/withStorybook");/** withStorybook Adds the config that storybook uses */module.exports = withStorybook(config);
Create a Storybook route
In Expo Router, create a new route for Storybook by adding app/storybook.tsx:
export { default } from '../.rnstorybook';
This creates a /storybook route that you can navigate to within your app.
Configure your storybook route to hide the header and make storybook only accessible in dev (or based on your own logic). To do this edit your layout file app/_layout.tsx like so:
// app/_layout.tsximport { Stack } from "expo-router";export default function RootLayout() {return (<Stack screenOptions={{ headerShown: false }}><Stack.Screen name="index" /><Stack.Protected guard={__DEV__}><Stack.Screen name="storybook" /></Stack.Protected></Stack>);}
Using Stack.Protected here makes it so the screen will only be accessible in dev, you can expand on this by using an environment variable or other logic to change how this works.
Running Storybook
The only thing left to do is run your app.
- npm run startNow to access storybook by add a link to the /storybook route in your app. For now lets add it in the index app/index.tsx .
import { Link } from "expo-router";import { View } from "react-native";export default function Index() {return (<Viewstyle={{flex: 1,justifyContent: "center",alignItems: "center",}}><Link href="/storybook">Open Storybook</Link></View>);}
When you open the storybook screen you should see something like this:
Write stories for your components
In Storybook a story is an example of your components in a specific UI state. For example I can make Loading and Disabled stories that shoes my button in each in those states.
Lets build out an input component to show off the different Storybook features.
In a moment we’ll create a components folder for our new components and stories. In order for storybook to pick up stories in that folder we need to add the components directory to our stories regex in .rnstorybook/main.ts:
// .rnstorybook/main.tsimport { StorybookConfig } from "@storybook/react-native";const main: StorybookConfig = {stories: ["./stories/**/*.stories.?(ts|tsx|js|jsx)",// the paths are relative to the main.ts file itself"../components/**/*.stories.?(ts|tsx|js|jsx)", // <--- Add this],addons: ["@storybook/addon-ondevice-controls","@storybook/addon-ondevice-actions",],};export default main;
🚨 Important Note:
Make sure to restart metro after making changes to your stories regex. This will trigger generation of the storybook.requires.ts file which enables Storybook to watch the new directories you included.
Lets create a simple input component with a few states. Add a input.tsx file in the components directory (create the directory if you haven’t already):
// components/input.tsximport { useState } from "react";import { Text, TextInput, View, type TextInputProps } from "react-native";type InputProps = TextInputProps & {label?: string;error?: string;disabled?: boolean;};const getBorderColor = (isFocused: boolean, error?: string) => {if (error) {return "#FF3B30";}return isFocused ? "#007AFF" : "#D1D1D6";};export const Input = ({ disabled, label, error, ...props }: InputProps) => {const [isFocused, setIsFocused] = useState(false);const borderColor = getBorderColor(isFocused, error);return (<View style={{ gap: 4 }}><Text id="input-label" style={{ fontSize: 14, color: "#3C3C43" }}>{label}</Text><TextInputaria-labelledby="input-label"aria-disabled={disabled}style={{borderWidth: 1,padding: 12,borderRadius: 8,borderColor,backgroundColor: disabled ? "#F5F5F5" : "transparent",}}editable={!disabled}onFocus={() => {setIsFocused(true);}}onBlur={() => {setIsFocused(false);}}{...props}/>{error && <Text style={{ fontSize: 12, color: "#FF3B30" }}>{error}</Text>}</View>);};
Add a input.stories.tsx file in the components directory like this:
// components/input.stories.tsximport { Meta, StoryObj } from "@storybook/react-native";import { Input } from "./input";const meta = {component: Input,} satisfies Meta<typeof Input>;export default meta;type Story = StoryObj<typeof meta>;export const Basic: Story = {args: {label: "First Name",placeholder: "John",},};
Now you’ve got your basic story and you’ll see the input in your storybook with the “First Name” label and “John” placeholder.
The args of a story set the default props passed to the component when the story renders, you can then adjust the state in realtime using the controls panel.
Now we can add a few more states to our stories file by adding the Error and Disabled stories like so:
// components/input.stories.tsx// ... rest of the story file 👆export const Error: Story = {args: {label: "Email",error: "Email is required",disabled: false,placeholder: "example@example.com",},};export const Disabled: Story = {args: {label: "Disabled",error: "",disabled: true,placeholder: "Disabled",},};
Now you’ll see some new states show up in the menu and you can quickly go between them.
You might notice that our input component is flush with the sides of the ui and it might be nicer if we could add some kind of wrapper to add padding around it. This is exactly the kind of thing decorators can be used for.
Lets edit the meta for our stories file to add a decorator:
import { View } from "react-native"; // add this// components/input.stories.tsxconst meta = {component: Input,decorators: [// Here we're adding a container with 16px padding.// add this 👇(Story) => (<View style={{ padding: 16 }}><Story /></View>),],} satisfies Meta<typeof Input>;// Rest of your stories file here...
You should now see that there’s a bit more space around our component.
You can also use decorators for things like theme, mocking, or application state providers. You can also add decorators on the Story level for individual stories or globally in .rnstorybook/preview.tsx .
const meta = {component: Input,decorators: [// You can wrap your stories here with anything you like// Here I added an example ThemeProvider that could come from a UI library(Story) => (<ThemeProvider><View style={{ padding: 16 }}><Story/></View></ThemeProvider>),],} satisfies Meta<typeof Input>;
For more on writing stories please see the official docs here.
Share your Storybook
Storybook is great for developing components in isolation, but another superpower is the ability to share your components with your teammates.
Before moving forward lets setup a method for enabling/disabling storybook.
Edit the _layout file to show storybook only when EXPO_PUBLIC_ENVIRONMENT is set to storybook.
// app/_layout.tsxexport default function RootLayout() {return (<Stack screenOptions={{ headerShown: false }}><Stack.Screen name="index" /><Stack.Protectedguard={process.env.EXPO_PUBLIC_ENVIRONMENT === "storybook"}><Stack.Screen name="storybook" /></Stack.Protected></Stack>);}
You can then add a script to package.json to run with the environment set to storybook
"storybook": "EXPO_PUBLIC_ENVIRONMENT='storybook' expo start",
Now when you want to develop you components in storybook you can run npm run storybook and then to develop your app as normal run npm run start.
Note that we didn’t put anything else in our app and we won’t focus on that for now, but you could now hide your link to storybook if the environment is not storybook.
Sharing for iOS with TestFlight
You’ll need an Apple developer account to be able to use TestFlight, this gives you the ability to publish to the app store and use TestFlight for internal and public testing. You’ll also need an expo account to use eas. Create your expo account here: https://expo.dev/signup.
Once you have that setup we can move forward.
Using TestFlight enables you to share your app easily with beta testers or team members, EAS comes with some nice tools to automate its use.
Start by installing the EAS cli with this command:
npm install -g eas-cli
Now lets setup our EAS project and configure for builds and eas updates. Run each of these commands and follow the prompts.
eas logineas initeas build:configure -p alleas update:configure
Now you should have an eas.json file setup for building on EAS and for EAS updates.
Next lets update that eas.json to make a storybook build with a different name and identifier so it will have its own TestFlight distribution separate from your main app.
{"build": {// other build types...// add storybook build type here"storybook": {"distribution": "store","channel": "storybook","autoIncrement": true,"ios": {"simulator": false},"env": {"EXPO_PUBLIC_ENVIRONMENT": "storybook",}}},"submit": {// other submit types...// add storybook submit type here"storybook": {"ios": {"bundleIdentifier": "com.example.myapp.storybook","appName": "My App Storybook"}}}}
Add a new file called app.config.ts which will extend our existing app.json config and add some dynamic values. We need this because we want to change our bundle identifier based on which build we are using.
import { type ExpoConfig, ConfigContext } from 'expo/config';function getBundleIdentifier() {const isStorybook = process.env.EXPO_PUBLIC_ENVIRONMENT === 'storybook';if (isStorybook) {return 'com.example.myapp.storybook';}return 'com.example.myapp';}function config({ config }: ConfigContext): Partial<ExpoConfig> {return {// this is the config from app.json...config,ios: {...config.ios,bundleIdentifier: getBundleIdentifier(),infoPlist: {...config.ios?.infoPlist,ITSAppUsesNonExemptEncryption: false,},},};}export default config;
Now to build our app and submit to app center:
eas build -p ios --submit --profile storybook
Follow the prompts and if all goes well then you’ll then get an email saying that you were invited to TestFlight, after accepting this you should be able to access the app.
You can now to publish to test flight your app with Storybook and your production app in a convenient way.
Now to share with other team members you can add them to your testing team for test flight by visiting app store connect.
Android
For android you can make either an internal testing build available on the play store or you can create an internal distribution with an apk buildType:
{"build": {// other build types..."storybook": {"distribution": "store","channel": "storybook","autoIncrement": true,"ios": {"simulator": false},"env": {"EXPO_PUBLIC_ENVIRONMENT": "storybook"}},// 👇 for internal sharing of apk builds"storybook-internal": {"extends": "storybook","distribution": "internal","android": { "buildType": "apk" }}},}
Then update your app.config.ts
function config({ config }: ConfigContext): Partial<ExpoConfig> {return {// this is the config from app.json...config,// rest of the config...android: {...config.android,package: getBundleIdentifier(),},};}
Now to create an internal build run:
eas build -p android --profile storybook-internal
When you open up the build in eas you’ll see a button to install that will give you a QR code and a link that you can use to download the apk directly.
Publishing on the Web
The React Native Storybook UI is compatible with React Native Web so if you are supporting the web with your components then you’ll be able to use expo export and eas deploy to deploy your stories on the web.
First build your app for web:
EXPO_PUBLIC_ENVIRONMENT=storybook bun expo export --platform web
Then deploy:
eas deploy
Heres one I deployed earlier: https://sharestorybook--qclgxuc645.expo.app/
Turn your stories into tests
Another benefit of documenting your components with stories is that you can easily reuse those UI states in automated tests.
For example, to test our Input story above, we can “compose” the story, which turns it into a React component that can be used with Jest and Testing Library.
Unit testing is supported via portable stories, this gives you the ability to run jest and testing library style tests whilst reusing the stories you create for your components.
To get started install the dependencies:
npx expo install --dev jest-expo jest @types/jest @testing-library/react-native pretty-format@29.7.0
Now add to package.json a script to run your tests and the jest config:
{"scripts": {"test": "jest --watchAll",},"jest": {"preset": "jest-expo"},}
We can add a input.test.tsx to try it out:
// Button.test.tsximport { composeStories } from "@storybook/react";import { render, screen } from "@testing-library/react-native";import * as stories from "./input.stories";// Every story is returned as a composed componentconst { Basic } = composeStories(stories);test("renders primary button with default args", () => {render(<Basic />);const label = screen.getByText("First Name");expect(label).toBeTruthy();});
Now if you run npm run test here’s what that looks like in the CLI:
If we update our code in a way that breaks the Input render, these tests would fail our CI:
We’re actively working on bringing even more powerful tests to React Native Storybook. For updates, follow me on X or Bluesky to get notified of the latest improvements.
Storybook 9 is out!
This post is written for Storybook 9, which was recently released in June. It brings several exciting improvements specifically for React Native developers:
- Simplified configuration: The new folder structure makes it easier to run both web and React Native Storybook side by side.
- Lite mode UI: A lighter version of the UI written without the extra dependencies that allows Storybook to run on TV or MacOS.
- Better controls: Enhanced conditional args and better compatibility with web
- 48% leaner core: Storybook's core packages are dramatically smaller with a flatter dependency structure.
Try Storybook 9 today
As you can see Storybook offers you tools to iterate on your design system with ease and without the manual steps of navigating through your app. Combined with expo you can share TestFlight builds of your design system with your designers and other stakeholders to get early feedback.
To quickly try out storybook you can try this template:
npx create-expo-app --template expo-template-storybook@latest AwesomeStorybook54
For more on the changes in Storybook 9 check out this link: https://storybook.js.org/blog/storybook-9/
And take a look at the documentation site for React Native to learn more about this integration: https://storybookjs.github.io/react-native/docs/intro/getting-started/


