JoyConf 2026 is back. Content Confidence. Human Connection. Save your spot!

Preview React Native apps in Storyblok with an Expo web build

Storyblok is the first headless CMS that works for developers & marketers alike.

The Visual Editor shows editors how their content looks in a live preview of your site. As editors author content, the preview updates in real time. The Visual Editor helps editors work faster without context switching: authoring and previewing happen in the same interface.

React Native developers build mobile apps that compile to binaries, which a browser can’t load. The content still reaches the app at runtime, but there’s nothing for the Visual Editor to display. To check a change, an editor has to leave Storyblok, open the app on a device, navigate to the right screen, and refresh.

Expo solves this problem by giving your React Native app the ability to produce a web build. The web build renders the same components from the same code, so you can host it and point the Visual Editor at it. You then get the same experience as web front ends—click-to-edit, live updates, and block highlighting—without a second codebase.

This guide builds a React Native and Expo app end to end. The result is a single project that fetches content from Storyblok and renders it natively on iOS and Android, while its web build serves as the live preview for the Visual Editor.

This tutorial uses the following versions:

  • React Native 0.86
  • React 19
  • Expo SDK 57
  • @storyblok/api-client 0.7.4

How live preview works in React Native

The Visual Editor renders your app inside an iframe and communicates with the app over postMessage. The Visual Editor needs a DOM that loads over HTTPS, elements it can annotate, and a script it can talk to. A compiled iOS or Android app offers none of those, so a native build can’t serve as a preview target for the Visual Editor.

Expo closes this gap through React Native for Web: the same components that render to UIView and android.view.View on a device render to real DOM nodes in a browser. For example, <View> becomes a <div>, and <Text> becomes a <div> with text. The web build is a legitimate preview target and remains the same app.

The result is one project that serves two targets:

Target

Role

iOS and Android

Shipping app (read-only, no bridge)

Web (react-native-web)

Visual Editor preview

Three pieces make the Visual Editor work, and this tutorial builds them in order:

  1. Editable attributes. Each block’s _editable field becomes the data-blok-* attributes the bridge scans for.
  2. Slug resolution. The editor requests the path of the story being edited, so the app must resolve arbitrary slugs.
  3. The bridge. A script pushes every keystroke from the editor into the app.
Hint:

Already have a React Native app? If your project already fetches and renders Storyblok content, skip the setup and continue with Make blocks editable.

Setup

Set up the project and create a Storyblok space.

Hint:

No Storyblok account? Sign up for Storyblok.

  1. Log in to Storyblok and create a new space.
  2. Create an Expo project with the default template, which includes Expo Router and web support:
npx create-expo-app@latest my-app
cd my-app
npm run reset-project

reset-project moves the example views aside and leaves a blank src/app/ directory that contains only index.tsx and _layout.tsx. Delete both files—this tutorial creates .js versions of them later.

Expo Router treats every file inside /src/app as a URL.

Add dependencies

Install @storyblok/api-client, Storyblok’s TypeScript client for the Content Delivery API.

npm install @storyblok/api-client

Next, add your access token to a .env file at the project root.

.env
EXPO_PUBLIC_STORYBLOK_TOKEN=your_preview_token

Get an access token from your Storyblok space. Use a preview token so your app can request draft content.

Finally, create a single shared client for the whole app.

src/lib/storyblok.js
import { createApiClient } from '@storyblok/api-client';

export const storyblok = createApiClient({
	accessToken: process.env.EXPO_PUBLIC_STORYBLOK_TOKEN,
	region: 'eu',
});

Set region to match the server location of your Storyblok space. Learn more about Content Delivery API client regions.

Fetch a single story

Create a component that fetches one story by its full_slug and renders its blocks. Both the home route and the catch-all route you add later reuse this component.

src/components/StoryView.js
import { useCallback, useEffect, useState } from 'react';
import { ScrollView, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { storyblok } from '../lib/storyblok';

export default function StoryView({ slug }) {
	const [story, setStory] = useState(null);
	const [error, setError] = useState(null);

	const load = useCallback(async () => {
		setError(null);
		const { data, error } = await storyblok.stories.get(slug, {
			query: { version: 'draft' },
		});
		if (error) {
			setError(error.message);
			return;
		}
		setStory(data.story);
	}, [slug]);

	useEffect(() => {
		setStory(null);
		load();
	}, [load]);

	if (error) return <Text style={styles.message}>{error}</Text>;
	if (!story) return <ActivityIndicator style={styles.loading} size="large" />;

	return (
		<ScrollView contentContainerStyle={styles.content}>
			{story.content.body.map((blok) => (
				<Text key={blok._uid} style={styles.message}>
					Unknown block type '{blok.component}'
				</Text>
			))}
		</ScrollView>
	);
}

const styles = StyleSheet.create({
	content: { paddingBottom: 48 },
	loading: { marginTop: 64 },
	message: { margin: 24, fontSize: 15 },
});

Next, render StoryView from the home route.

src/app/index.js
import StoryView from '../components/StoryView';

export default function Index() {
	return <StoryView slug="home" />;
}

Add a root layout. Slot renders whichever route is currently active.

src/app/_layout.js
import { Slot } from 'expo-router';

export default function RootLayout() {
	return <Slot />;
}

Run your app in the browser:

npm run web

Because no block types are registered yet, the app renders only the fallback message for each block in the story, for example Unknown block type 'teaser'.

Resolve slugs with Expo Router

The Visual Editor loads your app in an iframe at the path of the story being edited. Expo Router’s file-based routing handles these paths with a catch-all route.

src/app/[...slug].js
import { useLocalSearchParams } from 'expo-router';
import StoryView from '../components/StoryView';

export default function CatchAll() {
	const { slug } = useLocalSearchParams();
	const fullSlug = Array.isArray(slug) ? slug.join('/') : slug || 'home';
	return <StoryView slug={fullSlug} />;
}

Expo Router provides the path segments as an array. For example, /articles/my-post arrives as ["articles", "my-post"].

Joining the segments with / reconstructs the Storyblok full_slug, which StoryView passes to the Content Delivery API.

Two routes now cover the entire space:

  • src/app/index.js → / → home story
  • src/app/[...slug].js → all other paths → matching full_slug

Create and register blocks

Storyblok returns each block as a plain JSON object with a component field that holds its technical name.

At the time of writing, a new Storyblok space includes four preconfigured components: Page, Feature, Teaser, and Grid.

Map these component names to React Native components.

src/components/blocks.js
import { View, Text, StyleSheet } from 'react-native';

function Page({ blok }) {
	return (
		<View>
			{(blok.body ?? []).map((nested) => (
				<Blok key={nested._uid} blok={nested} />
			))}
		</View>
	);
}

function Feature({ blok }) {
	return <Text style={styles.text}>{blok.name}</Text>;
}

function Teaser({ blok }) {
	return <Text style={styles.headline}>{blok.headline}</Text>;
}

function Grid({ blok }) {
	return (
		<View style={styles.grid}>
			{(blok.columns ?? []).map((column) => (
				<Blok key={column._uid} blok={column} />
			))}
		</View>
	);
}

const BLOCKS = {
	page: Page,
	feature: Feature,
	teaser: Teaser,
	grid: Grid,
};

export function Blok({ blok }) {
	if (!blok?.component) return null;
	const Component = BLOCKS[blok.component];
	if (!Component) {
		return (
			<Text style={styles.text}>Unknown block type '{blok.component}'</Text>
		);
	}
	return <Component blok={blok} />;
}

const styles = StyleSheet.create({
	text: { padding: 16, fontSize: 15 },
	headline: { padding: 16, fontSize: 22, fontWeight: '700' },
	grid: { flexDirection: 'row', flexWrap: 'wrap' },
});

Finally, render the blocks in StoryView instead of the placeholder. Import Blok and swap it in for the <Text> fallback.

Here’s the complete StoryView.js to replace the previous version:

src/components/StoryView.js
import { useCallback, useEffect, useState } from 'react';
import { ScrollView, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { storyblok } from '../lib/storyblok';
import { Blok } from './blocks';

export default function StoryView({ slug }) {
	const [story, setStory] = useState(null);
	const [error, setError] = useState(null);

	const load = useCallback(async () => {
		setError(null);
		const { data, error } = await storyblok.stories.get(slug, {
			query: { version: 'draft' },
		});
		if (error) {
			setError(error.message);
			return;
		}
		setStory(data.story);
	}, [slug]);

	useEffect(() => {
		setStory(null);
		load();
	}, [load]);

	if (error) return <Text style={styles.message}>{error}</Text>;
	if (!story) return <ActivityIndicator style={styles.loading} size="large" />;

	return (
		<ScrollView contentContainerStyle={styles.content}>
			{story.content.body.map((blok) => (
				<Blok key={blok._uid} blok={blok} />
			))}
		</ScrollView>
	);
}

const styles = StyleSheet.create({
	content: { paddingBottom: 48 },
	loading: { marginTop: 64 },
	message: { margin: 24, fontSize: 15 },
});

Run your app and check that the home story renders completely, with the teaser, feature, and grid blocks.

Make blocks editable

Rendering content is enough for the app itself, but the Visual Editor needs to know which part of the page corresponds to which component. The _editable field provides that mapping.

When you request the draft version, every block in the response carries an _editable string that contains an HTML comment.

The Visual Editor bridge scans the rendered page for two data attributes and matches them against that payload:

Attribute

Value

data-blok-c

The _editable object

data-blok-uid

{id}-{uid} from that object

Create a helper that parses the _editable field for each component and returns the props to apply to the block’s root element.

src/lib/editable.js
import { Platform } from 'react-native';

function parseEditable(editable) {
	if (typeof editable !== 'string') return null;
	try {
		const json = editable.replace(/^<!--#storyblok#/, '').replace(/-->$/, '');
		const options = JSON.parse(json);
		if (options?.uid && options.id != null) return options;
	} catch {
		// malformed, so ignore it
	}
	return null;
}

export function editableProps(blok) {
	if (Platform.OS !== 'web') return {};
	const options = parseEditable(blok?._editable);
	if (!options) return {};
	return {
		dataSet: {
			blokC: JSON.stringify(options), // → data-blok-c
			blokUid: `${options.id}-${options.uid}`, // → data-blok-uid
		},
	};
}

Update the blocks.js file you created earlier and apply the editableProps helper to a wrapping <View> element.

src/components/blocks.js
import { View, Text, StyleSheet } from 'react-native';
import { editableProps } from '../lib/editable';

function Page({ blok }) {
	return (
		<View>
			{(blok.body ?? []).map((nested) => (
				<Blok key={nested._uid} blok={nested} />
			))}
		</View>
	);
}

function Feature({ blok }) {
	return <Text style={styles.text}>{blok.name}</Text>;
}

function Teaser({ blok }) {
	return <Text style={styles.headline}>{blok.headline}</Text>;
}

function Grid({ blok }) {
	return (
		<View style={styles.grid}>
			{(blok.columns ?? []).map((column) => (
				<Blok key={column._uid} blok={column} />
			))}
		</View>
	);
}

const BLOCKS = {
	page: Page,
	feature: Feature,
	teaser: Teaser,
	grid: Grid,
};

export function Blok({ blok }) {
	if (!blok?.component) return null;
	const Component = BLOCKS[blok.component];
	return (
		<View {...editableProps(blok)}>
			{Component ? (
				<Component blok={blok} />
			) : (
				<Text style={styles.text}>Unknown block type '{blok.component}'</Text>
			)}
		</View>
	);
}

const styles = StyleSheet.create({
	text: { padding: 16, fontSize: 15 },
	headline: { padding: 16, fontSize: 22, fontWeight: '700' },
	grid: { flexDirection: 'row', flexWrap: 'wrap' },
});

Connect the preview bridge

The attributes you applied to each component tell the editor where each block is. The bridge makes edits appear without a reload. Once your app loads inside the iframe, the bridge emits events that your app subscribes to:

  • input: fires on every field edit.
  • change and published: fire on save or publish, and reload the page to get the latest version of the story.

Add a helper that subscribes to these events.

import { Platform } from 'react-native';

const BRIDGE_URL = 'https://app.storyblok.com/f/storyblok-v2-latest.js';

export function initStoryblokBridge(onInput) {
	if (Platform.OS !== 'web' || typeof document === 'undefined') return () => {};

	const start = () => {
		const bridge = new window.StoryblokBridge();
		bridge.on(['input'], (event) => onInput(event.story));
		bridge.on(['change', 'published'], () => window.location.reload());
	};

	const script = document.createElement('script');
	script.src = BRIDGE_URL;
	script.async = true;
	script.addEventListener('load', start);
	document.head.appendChild(script);

	return () => script.remove();
}

Subscribe to the bridge in StoryView. Here’s the complete version of StoryView.js:

src/components/StoryView.js
import { useCallback, useEffect, useState } from 'react';
import { ScrollView, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { storyblok } from '../lib/storyblok';
import { Blok } from './blocks';
import { initStoryblokBridge } from '../lib/bridge';

export default function StoryView({ slug }) {
	const [story, setStory] = useState(null);
	const [error, setError] = useState(null);

	const load = useCallback(async () => {
		setError(null);
		const { data, error } = await storyblok.stories.get(slug, {
			query: { version: 'draft' },
		});
		if (error) {
			setError(error.message);
			return;
		}
		setStory(data.story);
	}, [slug]);

	useEffect(() => {
		setStory(null);
		load();
	}, [load]);

	useEffect(() => initStoryblokBridge(setStory), []);

	if (error) return <Text style={styles.message}>{error}</Text>;
	if (!story) return <ActivityIndicator style={styles.loading} size="large" />;

	return (
		<ScrollView contentContainerStyle={styles.content}>
			{story.content.body.map((blok) => (
				<Blok key={blok._uid} blok={blok} />
			))}
		</ScrollView>
	);
}

const styles = StyleSheet.create({
	content: { paddingBottom: 48 },
	loading: { marginTop: 64 },
	message: { margin: 24, fontSize: 15 },
});

Serve your app over HTTPS

Storyblok serves the Visual Editor over HTTPS, so the Visual Editor only loads preview URLs served over HTTPS. How you implement HTTPS depends on your setup.

Expo doesn’t provide HTTPS for local development, so add a local proxy in front of the Expo dev server. Refer to Expo’s local HTTPS guide to set one up.

Deploy the web build

A localhost preview URL only resolves on the machine that runs the dev server. To share the preview with editors, host the web build.

Export the web build as static files:

npx expo export --platform web

The command produces a dist directory. The output contains nothing specific to React Native, so it needs no special build configuration and deploys to any static host, including the following services:

  • Vercel
  • Netlify
  • Cloudflare Pages
  • GitHub Pages
  • Expo’s own EAS Hosting

Register the deployed URL as a preview URL.

Set the preview URL

The last step tells Storyblok where to find the app. The configuration depends on whether the space already serves a website.

If only the React Native app uses the space

In your space, open Settings → Visual Editor and set your HTTPS URL as the Location (default environment). Every story now opens against this URL by default.

If the space already has a website

Keep the website’s default location in place and add the React Native app URL as an additional preview URL:

  1. In your space, open Settings → Visual Editor.
  2. Under Preview URLs, add a new entry that points to the app’s HTTPS URL.
  3. Select Save.

Storyblok now registers both the website URL and the mobile app URL against the same content.

Switch between front ends

Open any story and select the cog icon in the Visual Editor toolbar to switch between the configured URLs. The content stays identical—only the front-end rendering changes.

Conclusion

This completes your initial setup. The app you built fetches content from Storyblok and renders it with your own React Native components. Continue to build your React Native app, and let Expo produce the web build. Editors now author content for your mobile app the same way they author for the web: select a block, change it, and view the update. Both targets render the same components from the same source, so the preview shows the real app rather than an approximation built to look like it. Extend the BLOCKS map as your block library grows, and deploy the web build to keep the preview available.

Author

Surjit Bharath

Surjit Bharath

Surjit Bharath is a Principal Solutions Architect at Storyblok. He has spent over 25 years developing CMS and Commerce applications for agencies and vendors alike, and now helps customers unlock the full potential of Storyblok through best-practice implementation.