Render Personalized Components using Sanity.io in React-Native

β€’ ⏱︎ 8 min read

🎯 What Problem Are We Trying to Solve?

🧰 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:

// 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

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