Render Personalized Components using Sanity.io in React-Native
π― What Problem Are We Trying to Solve?
-
Our goal is to enrich a workout + recipe app, aiming to personalize the user experience significantly.
- Recipes are written using rich text for flexibility.
- On each recipe page, we aim to display a personalized UX, showing information pertinent to the userβs recent workouts or calorie intake over the week.
-
Our content extends beyond rich text to include:
- Internal links: redirect users within the app.
- External links: displayed with an
icon
and opened in anin-app-web-browser.
- Personalized inline custom components: displaying user-specific components like graphs, ensuring typo-free graph type selection.
π§° Managing Content with Sanity.io
Sanity.io, a headless CMS, allows us to define content schemas and then fetch this structured content as JSON via API calls.
After setting up your Sanity project, structure your schema files as follows:
π³
βββ ...
βββ schemas
Β Β βββ index.ts
Β Β βββ blockContent.ts
Β Β βββ graphType.ts
Β Β βββ recipe.ts
Export all the things
// schemas/index.ts
import blockContent from './blockContent'
import graphType from './graphType'
import recipe from './recipe'
export const schemaTypes = [graphType, recipe, blockContent]
Recipe Schema
// schemas/recipe.ts
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'recipe',
title: 'Recipe',
type: 'document',
fields: [
// Recipe title
// Instead of inlining the title as rich text with an `header tag`,
// this will give us the flexibility to use the title in the header or the main content
defineField({
name: 'title',
title: 'Title',
type: 'string'
}),
// Recipe Slug
// Used for building a link and can also be seen as a `recipe ID`
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
// helper function to create a `slug` based off the title
// Will generates a slug prefixed with `/recipes/`
options: {
source: 'title',
slugify: (input) =>
'/recipes/' + input.toLowerCase().replace(/\s+/g, '-').slice(0, 200)
}
}),
// Yup... the main image
defineField({
name: 'mainImage',
title: 'Main image',
type: 'image'
}),
// Rich text defined by the `blockContent` schema below
defineField({
name: 'body',
title: 'Body',
type: 'blockContent'
})
]
})
BlockContent Schema for Rich Text:
- Defines a list of mappings to be displayed on the rich text form
- Beyond the usual rich text stuff, this is where weβll define:
internal links
:- These redirect to a different
route
on the app
- These redirect to a different
external links
:- These links have an icon and open in an in-app-web-browser
personalized inline custom component
:- A specific
graph
that will display personalized information
- A specific
// schemas/blockContent.ts
import {defineType, defineArrayMember} from 'sanity'
import { SchemaIcon, TriangleOutlineIcon } from '@sanity/icons'
export default defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
title: 'Block',
type: 'block',
// regular rich text stuff
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H1', value: 'h1' }
// ...
],
marks: {
//...
annotations: [
{
// Internal Link:
// This can **only** reference another `recipe` object
title: 'Internal link',
name: 'internalLink',
type: 'object',
fields: [
{
name: 'reference',
type: 'reference',
title: 'Reference',
icon: SchemaIcon,
to: [{
type: 'recipe'
}]
}
]
},
// External Link:
// Regular link with a title and a `href`
{
title: 'External Link',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url'
}
]
},
// `Personalized inline custom component`:
// Much link the `internal link` this points to a specific object
// of type `graphType`
{
title: 'Graph Type',
name: 'graphType',
type: 'object',
icon: TriangleOutlineIcon,
fields: [{
name: 'reference',
type: 'reference',
title: 'Graph Reference',
to: [{
type: 'graphType'
}]
}]
}
]
}
})
]
})
Custom Component Schema
- Defines a type for our inline component
- This gives us control/flexibility for what can be used
import { defineField, defineType } from 'sanity'
export default defineType({
name: 'graphType',
title: 'Graph Type',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string'
}),
// slug, like in the recipe, will be used as an `ID`
defineField({
name: 'slug',
title: 'Slug',
type: 'slug'
})
]
})
ποΈ Converting API Responses to React-Native Components
Assuming your app is set up with expo and structured as follows, weβll go over how to fetch content from Sanity and render it using custom React-Native components.
π³
βββ api
βΒ Β βββ sanity.ts
βββ app
βΒ Β βββ recipes
βΒ Β βββ [slug].tsx
βββ components
Β Β βββ BarChart.tsx
Β Β βββ ExternalLink.tsx
Β Β βββ InternalLink.tsx
Β Β βββ ProgressRing.tsx
Β Β βββ Recipe.tsx
βββ PortableTextComponents
Β Β βββ PortableText.tsx
Β Β βββ block.tsx
Β Β βββ defaults.tsx
Β Β βββ index.ts
Β Β βββ list.tsx
Β Β βββ marks.tsx
Β Β βββ styles.ts
Β Β βββ unknown.tsx
When you query your Sanity projectβs API, your rich text content is returned as Portable Text, a format designed for representing rich text in JSON.
While react-native-portabletext provides a handy layer for rendering Portable Text in React-Native apps via react-portabletext, weβll adopt this pattern but customize our approach for increased flexibility.
Querying Data from Sanity
Sanityβs query language is used to fetch data. We define a query to retrieve a recipe by slug, including references for internal links and graph types.
// api/sanity.ts
import imageBuilder from "@sanity/image-url"
import { createClient } from "@sanity/client"
import type { PortableTextBlock } from "@portabletext/types"
import type { ImageAsset, Slug } from "@sanity/types"
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: "production",
apiVersion: "2021-10-21",
useCdn: false
})
// Filter a recipe by query param `slug`
// - attach a reference for things we'll need on `internalLink` and `graphType` objects
//
// π - NOTE: that for `internalLink` we're pulling in `slug` AND the reference object's `mainImage`
export const RECIPE_QUERY = `*[_type == "recipe" && slug.current == $slug][0]{
...,
body[]{
...,
markDefs[]{
...,
_type == "internalLink" => {
"slug": @.reference->slug,
"image": @.reference->mainImage
},
_type == "graphType" => {
"slug": @.reference->slug
},
}
}
}`
export type RecipeT = {
_type: "recipe"
_id: string
_createdAt: string
title: string
slug: Slug
mainImage: ImageAsset
body: PortableTextBlock[]
}
export async function getRecipe(slug: string): Promise<RecipeT> {
return client.fetch(RECIPE_QUERY, { slug })
}
/**
This is a helper function to covert an `ImageAsset` from the api into something we can use
@usage:
<Image source={{ uri: urlFor(recipe.mainImage).url() }}
*/
const builder = imageBuilder(client)
export const urlFor = (source: string) => builder.image(source)
Rendering the Response
// @/components/Recipe.tsx
import { View, Text } from "react-native"
import { PortableText } from "@/components/PortableTextComponents"
import { type RecipeT } from "@/api/sanity"
// `<PortableText />` is a fork of react-native-portabletext
export default function Recipe({ recipe }: { recipe: RecipeT }) {
// `recipe.title` being separate from the rich text
// allows more flexibility to add the title where we want
return (
<View>
<Text>{recipe.title}</Text>
<PortableText value={recipe.body} />
</View>
)
}
// @/components/PortableTextComponents/deafults.tsx
import { defaultMarks } from "./marks"
export const defaultComponents: PortableTextReactComponents = {
types: {},
marks: defaultMarks,
//...
Configuring Custom Components
We map each custom component type from our Sanity content to a corresponding React-Native component. This setup allows us to dynamically render different types of content, including internal links, external links, and personalized graphs, based on the content fetched from Sanity.
// @/components/PortableTextComponents/marks.tsx
// ...imports omitted for brevity
export const defaultMarks: Record<string, PortableTextMarkComponent | undefined> = {
// style "strong/bold" text elements
strong: ({ children }) => <Text style={markStyles.strong}>{children}</Text>,
// π link:
// - this will use an `<ExternalLink />` component
// that takes the `href` value we defined and opens it in an in-app-web-browser
link: ({value, children}) => <ExternalLink href={value.href}>{children}</ExternalLink>,
// π internalLink:
// - we defined this specific type in the sanity schema
// - remember that time that we added this to RECIPE_QUERY:
// _type == "internalLink" => {
// "slug": @.reference->slug,
// "image": @.reference->mainImage
// }
// - we now have access to the referenced object's `image` and `slug`
// - internalLinks will route to a page in the app and display that page's main image inline
internalLink: ({ value, children }) => {
const { slug = {}, image } = value
return (
<InternalLink href={slug.current} imageUrl={urlFor(image).url()}>
{children}
</InternalLink>
)
},
// π graphType:
// - we treat the `graphType.slug` as an ID and render the appropriate component based on it
// - that component can be async and in context to the current logged in user
graphType: ({ value }) => {
const { slug = {} } = value
switch (slug.current) {
case "progress-ring":
return <ProgressRing />
case "bar-chart":
return <BarChart />
default:
return <></>
}
},
// ...
}
Styles: We define styles for our elements, ensuring a consistent look and feel across the app.
// @/components/PortableTextComponents/styles.ts
export const listStyles = StyleSheet.create({
list: {
marginVertical: 16,
color: Colors.rgb(Colors.text1)
},
//...
})
export const textStyles = StyleSheet.create({
h1: {
fontWeight: "bold",
fontSize: 32,
fontFamily: "recoleta-b",
color: Colors.rgb(Colors.text1)
},
//...
})
export const markStyles = StyleSheet.create({
strong: {
fontFamily: "poppins-b",
fontWeight: "bold",
fontSize: 16,
lineHeight: 24
},
...
})
β What we ended up with
- Personalized experience
- Managed through a CMS
- Strongly typed, typos are avoided in the CMS