Overview
This integration guide shows you how to how to update your existing project to:- install the ButterCMS package
- instantiate ButterCMS
- create components to fetch and display each of the three ButterCMS content types: Pages, Collections, and Blog Posts.
In order for the snippets to work, you’ll need to setup your dashboard content schemas inside of ButterCMS first.
Installation
- npm
- yarn
- pnpm
npm install buttercms react-native-render-html
yarn add buttercms react-native-render-html
pnpm add buttercms react-native-render-html
npm install react-native-config
Initialize the client
- TypeScript
- JavaScript
// lib/buttercms.ts
import Butter from 'buttercms';
import Config from 'react-native-config';
const butter = Butter(Config.BUTTERCMS_API_TOKEN);
export default butter;
// lib/buttercms.js
import Butter from 'buttercms';
import Config from 'react-native-config';
const butter = Butter(Config.BUTTERCMS_API_TOKEN);
export default butter;
.env:
BUTTERCMS_API_TOKEN=your_api_token
For complete SDK documentation including all available methods and configuration options, see the JavaScript SDK Reference.
Custom hooks
- useState Hooks
- React Query
// hooks/useButter.ts
import { useState, useEffect, useCallback } from 'react';
import butter from '../lib/buttercms';
interface UsePageResult<T> {
page: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export function usePage<T>(pageType: string, slug: string): UsePageResult<T> {
const [page, setPage] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchPage = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await butter.page.retrieve(pageType, slug);
setPage(response.data.data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [pageType, slug]);
useEffect(() => {
fetchPage();
}, [fetchPage]);
return { page, loading, error, refetch: fetchPage };
}
export function useCollection(keys: string[]) {
const [data, setData] = useState<Record<string, any> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchCollection() {
try {
const response = await butter.content.retrieve(keys);
setData(response.data.data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}
fetchCollection();
}, [keys.join(',')]);
return { data, loading, error };
}
export function usePosts(page = 1, pageSize = 10) {
const [posts, setPosts] = useState<any[]>([]);
const [meta, setMeta] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchPosts() {
setLoading(true);
try {
const response = await butter.post.list({ page, page_size: pageSize });
setPosts(response.data.data);
setMeta(response.data.meta);
} finally {
setLoading(false);
}
}
fetchPosts();
}, [page, pageSize]);
return { posts, meta, loading };
}
export function usePost(slug: string) {
const [post, setPost] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchPost() {
try {
const response = await butter.post.retrieve(slug);
setPost(response.data.data);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}
fetchPost();
}, [slug]);
return { post, loading, error };
}
// hooks/useButter.ts (with React Query)
import { useQuery } from '@tanstack/react-query';
import butter from '../lib/buttercms';
export function usePage<T>(pageType: string, slug: string) {
return useQuery({
queryKey: ['page', pageType, slug],
queryFn: async () => {
const response = await butter.page.retrieve(pageType, slug);
return response.data.data as T;
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useCollection(keys: string[]) {
return useQuery({
queryKey: ['collection', ...keys],
queryFn: async () => {
const response = await butter.content.retrieve(keys);
return response.data.data;
},
staleTime: 1000 * 60 * 5,
});
}
export function usePosts(page = 1, pageSize = 10) {
return useQuery({
queryKey: ['posts', page, pageSize],
queryFn: async () => {
const response = await butter.post.list({ page, page_size: pageSize });
return {
posts: response.data.data,
meta: response.data.meta
};
},
staleTime: 1000 * 60 * 5,
});
}
export function usePost(slug: string) {
return useQuery({
queryKey: ['post', slug],
queryFn: async () => {
const response = await butter.post.retrieve(slug);
return response.data.data;
},
staleTime: 1000 * 60 * 5,
});
}
Pages
- useState
- React Query
// screens/LandingPage.tsx
import React from 'react';
import {
ScrollView,
Text,
Image,
StyleSheet,
ActivityIndicator,
View
} from 'react-native';
import { usePage } from '../hooks/useButter';
import RenderHtml from 'react-native-render-html';
import { useWindowDimensions } from 'react-native';
interface PageFields {
headline: string;
subheadline: string;
hero_image?: string;
body: string;
}
interface PageData {
fields: PageFields;
slug: string;
}
export default function LandingPage({ route }) {
const { slug = 'home' } = route.params || {};
const { page, loading, error } = usePage<PageData>('landing-page', slug);
const { width } = useWindowDimensions();
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error || !page) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Page not found</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<Text style={styles.headline}>{page.fields.headline}</Text>
<Text style={styles.subheadline}>{page.fields.subheadline}</Text>
{page.fields.hero_image && (
<Image
source={{ uri: page.fields.hero_image }}
style={styles.heroImage}
resizeMode="cover"
/>
)}
<RenderHtml
contentWidth={width - 32}
source={{ html: page.fields.body }}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
error: { textAlign: 'center', color: '#ff3b30' },
headline: { fontSize: 28, fontWeight: 'bold', marginBottom: 8 },
subheadline: { fontSize: 16, color: '#666', marginBottom: 16 },
heroImage: { width: '100%', height: 200, borderRadius: 8, marginBottom: 16 },
});
// screens/LandingPage.tsx
import React from 'react';
import {
ScrollView,
Text,
Image,
StyleSheet,
ActivityIndicator,
View
} from 'react-native';
import { usePage } from '../hooks/useButter';
import RenderHtml from 'react-native-render-html';
import { useWindowDimensions } from 'react-native';
export default function LandingPage({ route }) {
const { slug = 'home' } = route.params || {};
const { data: page, isLoading, isError } = usePage('landing-page', slug);
const { width } = useWindowDimensions();
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (isError || !page) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Page not found</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<Text style={styles.headline}>{page.fields.headline}</Text>
<Text style={styles.subheadline}>{page.fields.subheadline}</Text>
{page.fields.hero_image && (
<Image
source={{ uri: page.fields.hero_image }}
style={styles.heroImage}
resizeMode="cover"
/>
)}
<RenderHtml
contentWidth={width - 32}
source={{ html: page.fields.body }}
/>
</ScrollView>
);
}
Collections
- useState
- React Query
// screens/BrandsScreen.tsx
import React from 'react';
import {
FlatList,
Text,
Image,
View,
StyleSheet,
ActivityIndicator
} from 'react-native';
import { useCollection } from '../hooks/useButter';
import RenderHtml from 'react-native-render-html';
import { useWindowDimensions } from 'react-native';
export default function BrandsScreen() {
const { data, loading, error } = useCollection(['brands']);
const { width } = useWindowDimensions();
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error || !data?.brands) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Failed to load brands</Text>
</View>
);
}
return (
<FlatList
data={data.brands}
keyExtractor={(item, index) => `brand-${index}`}
contentContainerStyle={styles.container}
renderItem={({ item }) => (
<View style={styles.brandCard}>
{item.logo && (
<Image source={{ uri: item.logo }} style={styles.logo} />
)}
<Text style={styles.brandName}>{item.name}</Text>
<RenderHtml
contentWidth={width - 64}
source={{ html: item.description }}
/>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
error: { color: '#ff3b30' },
brandCard: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
logo: { width: 80, height: 80, borderRadius: 8, marginBottom: 12 },
brandName: { fontSize: 20, fontWeight: '600', marginBottom: 8 },
});
// screens/BrandsScreen.tsx
import React from 'react';
import { FlatList, Text, Image, View, StyleSheet, ActivityIndicator } from 'react-native';
import { useCollection } from '../hooks/useButter';
export default function BrandsScreen() {
const { data, isLoading, isError } = useCollection(['brands']);
if (isLoading) {
return <ActivityIndicator size="large" style={styles.centered} />;
}
if (isError || !data?.brands) {
return <Text style={styles.error}>Failed to load brands</Text>;
}
return (
<FlatList
data={data.brands}
keyExtractor={(item, index) => `brand-${index}`}
renderItem={({ item }) => (
<View style={styles.brandCard}>
<Image source={{ uri: item.logo }} style={styles.logo} />
<Text style={styles.brandName}>{item.name}</Text>
</View>
)}
/>
);
}
Dynamic components
Component Renderer
// components/ComponentRenderer.tsx
import React from 'react';
import { View } from 'react-native';
import HeroComponent from './HeroComponent';
import FeaturesComponent from './FeaturesComponent';
import CTAComponent from './CTAComponent';
interface Component {
type: string;
fields: Record<string, any>;
}
interface Props {
components: Component[];
}
export default function ComponentRenderer({ components }: Props) {
return (
<View>
{components.map((component, index) => {
switch (component.type) {
case 'hero':
return <HeroComponent key={index} fields={component.fields} />;
case 'features':
return <FeaturesComponent key={index} fields={component.fields} />;
case 'cta':
return <CTAComponent key={index} fields={component.fields} />;
default:
return null;
}
})}
</View>
);
}
Example Component
// components/HeroComponent.tsx
import React from 'react';
import { View, Text, Image, TouchableOpacity, StyleSheet, Linking } from 'react-native';
interface HeroFields {
headline: string;
subheadline: string;
image?: string;
button_label?: string;
button_url?: string;
}
export default function HeroComponent({ fields }: { fields: HeroFields }) {
const handlePress = () => {
if (fields.button_url) {
Linking.openURL(fields.button_url);
}
};
return (
<View style={styles.hero}>
<Text style={styles.headline}>{fields.headline}</Text>
<Text style={styles.subheadline}>{fields.subheadline}</Text>
{fields.button_label && (
<TouchableOpacity style={styles.button} onPress={handlePress}>
<Text style={styles.buttonText}>{fields.button_label}</Text>
</TouchableOpacity>
)}
{fields.image && (
<Image
source={{ uri: fields.image }}
style={styles.image}
resizeMode="cover"
/>
)}
</View>
);
}
const styles = StyleSheet.create({
hero: { padding: 24, alignItems: 'center' },
headline: { fontSize: 32, fontWeight: 'bold', textAlign: 'center', marginBottom: 12 },
subheadline: { fontSize: 16, color: '#666', textAlign: 'center', marginBottom: 20 },
button: { backgroundColor: '#007AFF', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
image: { width: '100%', height: 200, borderRadius: 12, marginTop: 20 },
});
Using in Pages
- useState
- React Query
// screens/ComponentPage.tsx
import React from 'react';
import { ScrollView, ActivityIndicator, View, Text, StyleSheet } from 'react-native';
import { usePage } from '../hooks/useButter';
import ComponentRenderer from '../components/ComponentRenderer';
interface ComponentPageFields {
body: Array<{ type: string; fields: Record<string, any> }>;
}
export default function ComponentPage({ route }) {
const { slug } = route.params;
const { page, loading, error } = usePage<{ fields: ComponentPageFields }>('landing-page', slug);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error || !page) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Page not found</Text>
</View>
);
}
return (
<ScrollView>
<ComponentRenderer components={page.fields.body || []} />
</ScrollView>
);
}
const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
error: { color: '#ff3b30' },
});
// screens/ComponentPage.tsx
import React from 'react';
import { ScrollView, ActivityIndicator, View, Text } from 'react-native';
import { usePage } from '../hooks/useButter';
import ComponentRenderer from '../components/ComponentRenderer';
export default function ComponentPage({ route }) {
const { slug } = route.params;
const { data: page, isLoading, isError } = usePage('landing-page', slug);
if (isLoading) {
return <ActivityIndicator size="large" />;
}
if (isError || !page) {
return <Text>Page not found</Text>;
}
return (
<ScrollView>
<ComponentRenderer components={page.fields.body || []} />
</ScrollView>
);
}
Blog
- Blog List
- Blog Post
- useState
- React Query
// screens/BlogList.tsx
import React from 'react';
import {
FlatList,
Text,
TouchableOpacity,
View,
Image,
StyleSheet,
ActivityIndicator,
} from 'react-native';
import { usePosts } from '../hooks/useButter';
export default function BlogList({ navigation }) {
const { posts, meta, loading } = usePosts();
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<FlatList
data={posts}
keyExtractor={(item) => item.slug}
contentContainerStyle={styles.container}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.postCard}
onPress={() => navigation.navigate('BlogPost', { slug: item.slug })}
>
{item.featured_image && (
<Image
source={{ uri: item.featured_image }}
style={styles.thumbnail}
/>
)}
<View style={styles.postContent}>
<Text style={styles.postTitle}>{item.title}</Text>
<Text style={styles.postAuthor}>
By {item.author.first_name} {item.author.last_name}
</Text>
<Text style={styles.postSummary} numberOfLines={2}>
{item.summary}
</Text>
</View>
</TouchableOpacity>
)}
/>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
postCard: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
thumbnail: { width: '100%', height: 150 },
postContent: { padding: 16 },
postTitle: { fontSize: 18, fontWeight: '600', marginBottom: 4 },
postAuthor: { fontSize: 14, color: '#666', marginBottom: 8 },
postSummary: { fontSize: 14, color: '#333' },
});
// screens/BlogList.tsx
import React from 'react';
import { FlatList, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { usePosts } from '../hooks/useButter';
export default function BlogList({ navigation }) {
const { data, isLoading } = usePosts();
if (isLoading) {
return <ActivityIndicator size="large" />;
}
return (
<FlatList
data={data?.posts || []}
keyExtractor={(item) => item.slug}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.postCard}
onPress={() => navigation.navigate('BlogPost', { slug: item.slug })}
>
<Text style={styles.postTitle}>{item.title}</Text>
<Text style={styles.postAuthor}>
By {item.author.first_name} {item.author.last_name}
</Text>
</TouchableOpacity>
)}
/>
);
}
- useState
- React Query
// screens/BlogPost.tsx
import React from 'react';
import {
ScrollView,
Text,
Image,
View,
StyleSheet,
ActivityIndicator
} from 'react-native';
import { usePost } from '../hooks/useButter';
import RenderHtml from 'react-native-render-html';
import { useWindowDimensions } from 'react-native';
export default function BlogPost({ route }) {
const { slug } = route.params;
const { post, loading, error } = usePost(slug);
const { width } = useWindowDimensions();
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error || !post) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Post not found</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>{post.title}</Text>
<Text style={styles.author}>
By {post.author.first_name} {post.author.last_name}
</Text>
<Text style={styles.date}>
{new Date(post.published).toLocaleDateString()}
</Text>
{post.featured_image && (
<Image
source={{ uri: post.featured_image }}
style={styles.featuredImage}
resizeMode="cover"
/>
)}
<RenderHtml
contentWidth={width - 32}
source={{ html: post.body }}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
error: { color: '#ff3b30' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
author: { fontSize: 14, color: '#666' },
date: { fontSize: 12, color: '#999', marginBottom: 16 },
featuredImage: { width: '100%', height: 200, borderRadius: 8, marginBottom: 16 },
});
// screens/BlogPost.tsx
import React from 'react';
import { ScrollView, Text, Image, ActivityIndicator } from 'react-native';
import { usePost } from '../hooks/useButter';
import RenderHtml from 'react-native-render-html';
export default function BlogPost({ route }) {
const { slug } = route.params;
const { data: post, isLoading, isError } = usePost(slug);
if (isLoading) return <ActivityIndicator size="large" />;
if (isError || !post) return <Text>Post not found</Text>;
return (
<ScrollView style={{ padding: 16 }}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>{post.title}</Text>
<RenderHtml source={{ html: post.body }} />
</ScrollView>
);
}
Navigation
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import LandingPage from './screens/LandingPage';
import BrandsScreen from './screens/BrandsScreen';
import ComponentPage from './screens/ComponentPage';
import BlogList from './screens/BlogList';
import BlogPost from './screens/BlogPost';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={LandingPage} />
<Stack.Screen name="Brands" component={BrandsScreen} />
<Stack.Screen name="Landing" component={ComponentPage} />
<Stack.Screen name="Blog" component={BlogList} />
<Stack.Screen name="BlogPost" component={BlogPost} />
</Stack.Navigator>
</NavigationContainer>
</QueryClientProvider>
);
}
Caching
// With React Query, caching is built-in
// Configure cache time in the QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
},
},
});
// Manual cache invalidation
import { useQueryClient } from '@tanstack/react-query';
function useInvalidateCache() {
const queryClient = useQueryClient();
const invalidatePages = () => queryClient.invalidateQueries({ queryKey: ['page'] });
const invalidatePosts = () => queryClient.invalidateQueries({ queryKey: ['posts'] });
const invalidateAll = () => queryClient.invalidateQueries();
return { invalidatePages, invalidatePosts, invalidateAll };
}
Resources
JavaScript SDK
Complete SDK reference
React Guide
React web integration
GitHub Repository
View source code
Content API
REST API documentation