Expo Tutorial
This guide will walk you through setting up Composify in your Expo project. We'll assume you already have an Expo project with Expo Router up and running. If not, start with the Expo getting started guide first.
First, make sure the mock API from our prerequisites guide is running on http://localhost:9000. We'll use it to read and write our page content.
Install Composify
Add Composify to your project with your preferred package manager:
npm install @composify/react --saveRegister components to the Catalog
Let's create a few simple components.
/* components/Heading.tsx */
import { type FC, type PropsWithChildren } from 'react';
import { Text, StyleSheet } from 'react-native';
type Props = PropsWithChildren<{
level?: 1 | 2 | 3;
weight?: 'semibold' | 'bold' | 'extrabold';
}>;
const TEXT_SIZE_BY_LEVEL = {
1: 48,
2: 36,
3: 24,
} as const;
const FONT_WEIGHT_BY_WEIGHT = {
semibold: '600',
bold: '700',
extrabold: '800',
} as const;
export const Heading: FC<Props> = ({ level = 1, weight = 'extrabold', children }) => (
<Text
style={[
styles.heading,
{
fontSize: TEXT_SIZE_BY_LEVEL[level],
fontWeight: FONT_WEIGHT_BY_WEIGHT[weight],
},
]}
>
{children}
</Text>
);
const styles = StyleSheet.create({
heading: {
color: '#1E1E1E',
},
});Now, let's register them in the Catalog:
/* components/Heading.tsx */
import { Catalog } from '@composify/react/renderer';
/* ... */
Catalog.register('Heading', {
component: Heading,
props: {
level: {
label: 'Heading Level',
type: 'radio',
options: [
{
label: '1',
value: 1,
},
{
label: '2',
value: 2,
},
{
label: '3',
value: 3,
},
],
default: 1,
},
weight: {
label: 'Font Weight',
type: 'select',
options: [
{
label: 'Semibold',
value: 'semibold',
},
{
label: 'Bold',
value: 'bold',
},
{
label: 'Extrabold',
value: 'extrabold',
},
],
default: 'extrabold',
},
children: {
label: 'Content',
type: 'text',
default: 'Server Driven UI made easy',
},
},
});Finally, create a central export file at components/index.ts so we can import them all with a single line:
export { Heading } from './Heading';
export { Body } from './Body';
export { Button } from './Button';
export { HStack } from './HStack';
export { VStack } from './VStack';Render a screen
With our components registered, let's render a screen. The Renderer takes the saved JSX and renders it using your components.
import '@/components';
import { Renderer } from '@composify/react/renderer';
import { useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
export default function Page() {
const [source, setSource] = useState<string | null>(null);
const { slug } = useLocalSearchParams<{ slug?: string }>();
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`http://localhost:9000/documents/${slug}`, {
cache: 'no-store',
});
const { content } = await res.json().catch(() => ({}));
setSource(
content ??
`
<VStack flex={1} alignHorizontal="center" alignVertical="center">
<Heading level={3} weight="semibold">Not Found</Heading>
</VStack>
`.trim()
);
};
fetchData();
}, [slug]);
if (!source) {
return null;
}
return <Renderer source={source} />;
}A index screen to bounce around:
import '@/components';
import { Link } from 'expo-router';
import { StyleSheet, View } from 'react-native';
export default function Page() {
return (
<View style={styles.container}>
<Link href="/foo" style={styles.link}>
Visit Page (/foo)
</Link>
<Link href="/editor/foo" style={styles.link}>
Visit Editor (/foo)
</Link>
<Link href="/bar" style={styles.link}>
Visit Page (/bar)
</Link>
<Link href="/editor/bar" style={styles.link}>
Visit Editor (/bar)
</Link>
<Link href="/baz" style={styles.link}>
Visit Page (/baz)
</Link>
<Link href="/editor/baz" style={styles.link}>
Visit Editor (/baz)
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 8,
},
link: {
fontSize: 18,
color: '#3B82F6',
},
});Now you can test it:
- Visit
/footo see the saved page. - Visit
/bazand you'll get a 404 because there's no data yet.
Set up the Editor
Now for the fun part: setting up the visual editor. To create or update content, we'll use the Editor component. Note that the editor runs on the web only. Rendering works everywhere.
import '@composify/react/style.css';
import '@/components';
import { Editor } from '@composify/react/editor';
import { useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
export default function EditorPage() {
const [source, setSource] = useState<string | null>(null);
const { slug } = useLocalSearchParams<{ slug: string }>();
if (!source) {
return null;
}
return <Editor title={slug} source={source} />;
}A few key points:
@composify/react/style.cssis required — it contains the core editor styles.- Import your components (
@/components) so they're available in the editor.
Load the initial source
First, we'll fetch the saved JSX from our API and pass it to the Editor as the source prop.
import '@composify/react/style.css';
import '@/components';
import { Editor } from '@composify/react/editor';
import { useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
export default function EditorPage() {
const [source, setSource] = useState<string | null>(null);
const { slug } = useLocalSearchParams<{ slug: string }>();
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`http://localhost:9000/documents/${slug}`, {
cache: 'no-store',
});
const { content } = await res.json().catch(() => ({}));
setSource(content ?? '<VStack size={{ height: 100 }} backgroundColor="#f8fafc" />');
};
fetchData();
}, [slug]);
if (!source) {
return null;
}
return <Editor title={slug} source={source} />;
}Open /foo and you should see the editor UI with the document loaded.
Handle saving
Right now, clicking Save does nothing. Let's wire it up to our API using the onSubmit handler.
import '@composify/react/style.css';
import '@/components';
import { Editor } from '@composify/react/editor';
import { useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
export default function EditorPage() {
const [source, setSource] = useState<string | null>(null);
const { slug } = useLocalSearchParams<{ slug: string }>();
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`http://localhost:9000/documents/${slug}`, {
cache: 'no-store',
});
const { content } = await res.json().catch(() => ({}));
setSource(content ?? '<VStack size={{ height: 100 }} backgroundColor="#f8fafc" />');
};
fetchData();
}, [slug]);
const handleSubmit = async (source: string) => {
await fetch(`http://localhost:9000/documents/${slug}`, {
method: 'DELETE',
}).catch(() => null);
await fetch('http://localhost:9000/documents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: slug,
content: source,
}),
});
Alert.alert('Saved successfully');
};
if (!source) {
return null;
}
return <Editor title={slug} source={source} onSubmit={handleSubmit} />;
}Now, when you hit Save, the editor sends the updated JSX to your /documents API.
Try it out!
- Navigate to
/fooin your app to see the saved content. - Open
/editor/foo, make a change, and tap Save — the rendered page updates instantly. - Navigate to
/bazand you'll see a 404. - Open
/editor/baz, create some content, tap Save, and the new page is live immediately 🎉
Wrapping up
And that's it! You now have:
- A document store (currently powered by json-server, but could be a real database later)
- An editor where you can visually compose pages using your own components
- A renderer that turns saved JSX back into real UI
Where to go from here?
- Replace json-server with a real database
- Add authentication and user permissions
- Deploy it so your whole team can collaborate
For unlimited bandwidth, built-in version history, and collaboration features, try Composify Cloud — or self-host it, since it's all open source.