├── .env
├── index.js
├── app
├── _layout.tsx
└── index.tsx
├── .storybook
├── native
│ ├── preview.ts
│ ├── index.ts
│ ├── main.ts
│ └── storybook.requires.js
├── web
│ ├── preview.ts
│ └── main.ts
└── stories
│ └── Button.stories.tsx
├── tsconfig.json
├── .gitignore
├── metro.config.js
├── app.json
├── babel.config.js
├── src
└── components
│ └── Button.tsx
├── README.md
└── package.json
/.env:
--------------------------------------------------------------------------------
1 | EXPO_PUBLIC_STORYBOOK_ENABLED=true
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import "expo-router/entry";
2 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "expo-router";
2 |
3 | let RootApp = () => {
4 | return ;
5 | };
6 |
7 | export default RootApp;
8 |
--------------------------------------------------------------------------------
/.storybook/native/preview.ts:
--------------------------------------------------------------------------------
1 | export const parameters = {
2 | controls: {
3 | matchers: {
4 | color: /(background|color)$/i,
5 | date: /Date$/,
6 | },
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["src/*"]
6 | }
7 | },
8 | "extends": "expo/tsconfig.base"
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 | storybook-static/
13 |
14 | # macOS
15 | .DS_Store
16 |
--------------------------------------------------------------------------------
/.storybook/native/index.ts:
--------------------------------------------------------------------------------
1 | import { getStorybookUI } from '@storybook/react-native';
2 |
3 | import './storybook.requires';
4 |
5 | const StorybookUIRoot = getStorybookUI({});
6 |
7 | export default StorybookUIRoot;
8 |
--------------------------------------------------------------------------------
/.storybook/native/main.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ["../stories/**/*.stories.?(ts|tsx|js|jsx)"],
3 | addons: [
4 | "@storybook/addon-ondevice-controls",
5 | "@storybook/addon-ondevice-actions",
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/.storybook/web/preview.ts:
--------------------------------------------------------------------------------
1 | export const parameters = {
2 | actions: { argTypesRegex: "^on[A-Z].*" },
3 | controls: {
4 | matchers: {
5 | color: /(background|color)$/i,
6 | date: /Date$/,
7 | },
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/metro.config.js:
--------------------------------------------------------------------------------
1 | const { getDefaultConfig } = require("expo/metro-config");
2 |
3 | module.exports = (async () => {
4 | let defaultConfig = await getDefaultConfig(__dirname);
5 | defaultConfig.resolver.resolverMainFields.unshift("sbmodern");
6 | return defaultConfig;
7 | })();
8 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "scheme": "acme",
4 | "web": {
5 | "bundler": "metro"
6 | },
7 | "name": "expo-router-storybook-starter",
8 | "slug": "expo-router-storybook-starter",
9 | "experiments": {
10 | "tsconfigPaths": true
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 | return {
4 | presets: ["babel-preset-expo"],
5 | plugins: [
6 | "react-native-reanimated/plugin",
7 | require.resolve("expo-router/babel"),
8 | ["babel-plugin-react-docgen-typescript", { exclude: "node_modules" }],
9 | ],
10 | };
11 | };
12 |
--------------------------------------------------------------------------------
/.storybook/web/main.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: [
3 | "../stories/**/*.stories.mdx",
4 | "../stories/**/*.stories.@(js|jsx|ts|tsx)",
5 | ],
6 | addons: [
7 | "@storybook/addon-links",
8 | "@storybook/addon-essentials",
9 | "@storybook/addon-react-native-web",
10 | ],
11 | core: {
12 | builder: "webpack5",
13 | },
14 | framework: "@storybook/react",
15 | };
16 |
--------------------------------------------------------------------------------
/app/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Text, View } from "react-native";
3 | import { SafeAreaView } from "react-native-safe-area-context";
4 |
5 | const storybookEnabled = process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === "true";
6 |
7 | const Index = () => {
8 | return (
9 |
10 | Hello world
11 |
12 | );
13 | };
14 |
15 | let EntryPoint = Index;
16 |
17 | if (storybookEnabled) {
18 | const StorybookUI = require("../.storybook/native").default;
19 | EntryPoint = () => {
20 | return (
21 |
22 |
23 |
24 | );
25 | };
26 | }
27 |
28 | export default EntryPoint;
29 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { TouchableOpacity, Text, StyleSheet } from "react-native";
3 |
4 | export type MyButtonProps = {
5 | onPress?: () => void;
6 | text: string;
7 | disabled?: boolean;
8 | };
9 |
10 | export const MyButton: React.FC = ({
11 | onPress,
12 | text,
13 | disabled,
14 | }) => {
15 | return (
16 |
22 | {text}
23 |
24 | );
25 | };
26 |
27 | const styles = StyleSheet.create({
28 | container: {
29 | paddingHorizontal: 16,
30 | paddingVertical: 8,
31 | backgroundColor: "purple",
32 | borderRadius: 8,
33 | },
34 | text: { color: "white" },
35 | });
36 |
--------------------------------------------------------------------------------
/.storybook/stories/Button.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { View } from "react-native";
3 | import { MyButton, MyButtonProps } from "../../src/components/Button";
4 | import { Meta, StoryObj } from "@storybook/react-native";
5 |
6 | const meta: Meta = {
7 | title: "Button",
8 | component: MyButton,
9 | argTypes: {
10 | onPress: {
11 | action: "onPress event",
12 | },
13 | },
14 |
15 | decorators: [
16 | (Story) => (
17 |
18 |
19 |
20 | ),
21 | ],
22 | };
23 |
24 | export default meta;
25 |
26 | type Story = StoryObj;
27 |
28 | export const Basic: Story = {
29 | storyName: "Basic",
30 | args: {
31 | disabled: false,
32 | text: "Tap me",
33 | },
34 | };
35 |
36 | export const Disabled: Story = {
37 | args: {
38 | disabled: true,
39 | text: "Disabled",
40 | },
41 | };
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Storybook with Expo Router v2, SDK 49 & TypeScript
2 |
3 | - Working starter template that uses [`expo-router v2`](https://expo.github.io/router) to build native navigation using files in the `app/` directory.
4 | - It includes Storybook Web & Native configured with a few stories using TypeScript.
5 | - You can update the .env file to disable or enable storybook on your app.
6 | - It uses new features like Path Aliases, Opt out from package version validation, and client environment variables.
7 |
8 | ## 🚀 How to use
9 |
10 | ```sh
11 | npm install
12 |
13 | # Run on native
14 | npm start
15 |
16 | # Run on web
17 | npm run storybook:web
18 | ```
19 |
20 | If you want to disable storybook to work on your app, update the `.env` file like this:
21 |
22 | ```diff
23 | EXPO_PUBLIC_STORYBOOK_ENABLED=false
24 | ```
25 |
26 | You can learn more in these articles I wrote for this starter
27 |
28 | - [Using Storybook with Expo Router v2, SDK 49 & TypeScript](https://blog.spirokit.com/using-storybook-with-expo-router-v2-sdk-49-typescript)
29 | - [Setting Up Storybook Web and Native with Expo Router v2, SDK 49, and TypeScript](https://blog.spirokit.com/setting-up-storybook-web-and-native-with-expo-router-v2-sdk-49-and-typescript)
30 |
31 | ## 📝 Notes
32 |
33 | - [Expo Router: Docs](https://expo.github.io/router)
34 | - [Expo Router: Repo](https://github.com/expo/router)
35 | - [Storybook](https://storybook.js.org/)
36 |
37 | ## Shout-out
38 |
39 | - [oxeltra_beton](https://twitter.com/oxeltrabeton) for give me the idea to combine Storybook, Expo Router v2, and SDK 49 and write about it.
40 | - [Daniel Williams](https://twitter.com/Danny_H_W) for all his contributions to the React Native and Storybook communities.
41 |
--------------------------------------------------------------------------------
/.storybook/native/storybook.requires.js:
--------------------------------------------------------------------------------
1 | /* do not change this file, it is auto generated by storybook. */
2 |
3 | import {
4 | configure,
5 | addDecorator,
6 | addParameters,
7 | addArgsEnhancer,
8 | clearDecorators,
9 | } from "@storybook/react-native";
10 |
11 | global.STORIES = [
12 | {
13 | titlePrefix: "",
14 | directory: "./.storybook/stories",
15 | files: "**/*.stories.?(ts|tsx|js|jsx)",
16 | importPathMatcher:
17 | "^\\.[\\\\/](?:\\.storybook\\/stories(?:\\/(?!\\.)(?:(?:(?!(?:^|\\/)\\.).)*?)\\/|\\/|$)(?!\\.)(?=.)[^/]*?\\.stories\\.(?:ts|tsx|js|jsx)?)$",
18 | },
19 | ];
20 |
21 | import "@storybook/addon-ondevice-controls/register";
22 | import "@storybook/addon-ondevice-actions/register";
23 |
24 | import { argsEnhancers } from "@storybook/addon-actions/dist/modern/preset/addArgs";
25 |
26 | import { decorators, parameters } from "./preview";
27 |
28 | if (decorators) {
29 | if (__DEV__) {
30 | // stops the warning from showing on every HMR
31 | require("react-native").LogBox.ignoreLogs([
32 | "`clearDecorators` is deprecated and will be removed in Storybook 7.0",
33 | ]);
34 | }
35 | // workaround for global decorators getting infinitely applied on HMR, see https://github.com/storybookjs/react-native/issues/185
36 | clearDecorators();
37 | decorators.forEach((decorator) => addDecorator(decorator));
38 | }
39 |
40 | if (parameters) {
41 | addParameters(parameters);
42 | }
43 |
44 | try {
45 | argsEnhancers.forEach((enhancer) => addArgsEnhancer(enhancer));
46 | } catch {}
47 |
48 | const getStories = () => {
49 | return {
50 | "./.storybook/stories/Button.stories.tsx": require("../stories/Button.stories.tsx"),
51 | };
52 | };
53 |
54 | configure(getStories, module, false);
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "start": "sb-rn-get-stories --config-path .storybook/native && expo start",
4 | "android": "expo start --android",
5 | "ios": "expo start --ios",
6 | "web": "expo start --web",
7 | "storybook-generate": "sb-rn-get-stories",
8 | "storybook-watch": "sb-rn-watcher",
9 | "storybook:web": "start-storybook --config-dir .storybook/web -p 6006",
10 | "build-storybook": "build-storybook"
11 | },
12 | "dependencies": {
13 | "expo": "^49.0.0",
14 | "expo-constants": "~14.4.2",
15 | "expo-linking": "~5.0.2",
16 | "expo-router": "2.0.0",
17 | "expo-splash-screen": "~0.20.4",
18 | "expo-status-bar": "~1.6.0",
19 | "react": "18.2.0",
20 | "react-dom": "18.2.0",
21 | "react-native": "0.72.1",
22 | "react-native-gesture-handler": "~2.12.0",
23 | "react-native-reanimated": "~3.3.0",
24 | "react-native-safe-area-context": "4.6.3",
25 | "react-native-screens": "~3.22.0",
26 | "react-native-web": "~0.19.6"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.20.0",
30 | "typescript": "^5.1.3",
31 | "@types/react": "~18.2.14",
32 | "@babel/plugin-proposal-export-namespace-from": "^7.18.9",
33 | "@react-native-async-storage/async-storage": "^1.19.0",
34 | "@react-native-community/datetimepicker": "^7.4.0",
35 | "@react-native-community/slider": "^4.4.2",
36 | "@storybook/addon-actions": "^6.5.16",
37 | "@storybook/addon-controls": "^6.5.16",
38 | "@storybook/addon-essentials": "^6.5.16",
39 | "@storybook/addon-links": "^6.5.16",
40 | "@storybook/addon-ondevice-actions": "^6.5.4",
41 | "@storybook/addon-ondevice-controls": "^6.5.4",
42 | "@storybook/addon-react-native-web": "^0.0.21",
43 | "@storybook/react-native": "^6.5.4",
44 | "@storybook/react": "^6.5.16",
45 | "@storybook/builder-webpack5": "^6.5.14",
46 | "@storybook/manager-webpack5": "^6.5.14",
47 | "babel-plugin-react-docgen-typescript": "^1.5.1",
48 | "babel-plugin-react-native-web": "^0.18.10",
49 | "metro-react-native-babel-preset": "^0.77.0",
50 | "babel-loader": "^8.3.0"
51 | },
52 | "expo": {
53 | "install": {
54 | "exclude": [
55 | "@react-native-async-storage/async-storage",
56 | "@react-native-community/datetimepicker"
57 | ]
58 | }
59 | },
60 | "resolutions": {
61 | "react-docgen-typescript": "2.2.2",
62 | "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.cd77847.0"
63 | },
64 | "overrides": {
65 | "react-docgen-typescript": "2.2.2",
66 | "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.cd77847.0"
67 | },
68 | "name": "expo-router-storybook-starter",
69 | "version": "1.0.0",
70 | "private": true
71 | }
72 |
--------------------------------------------------------------------------------