├── .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 | --------------------------------------------------------------------------------