The "Page Builder" pattern is an incredibly popular one given the component-based nature of front end frameworks and modern website design.

Storyblok is an entire CMS built around this pattern. If you're coming from WordPress custom themes you'll be familiar with ACF Flexible Content.

I've been using Sanity recently and its highly customisable setup lends itself nicely to this pattern.


Example Repo

Since this guide is a whole load of code snippets, I've prepared a complete working repo for a Page Builder in Sanity and Gatsby.

It is not intended to be a fully complete website starter, just a working demo of specifically what we're looking at in this article.

Setup in Sanity

Our Page Builder is a number of Arrays of Objects stitched together. Because of the way we need to 'hoist' each one -- that is, register the Page Builder itself and each 'block' as its own type:.

First, we'll add our pageBuilder field to whichever document schema you want to have access to it.

(Note: GraphQL can be a pain querying an object this complex on more than one schema. If possible, limit the Page Builder to just one and filter the documents some other way.)

// schemas/documents/page.js
export default {
  name: "page",
  title: "Pages",
  type: "document",
  fields: [
    {
      name: "pageBuilder",
      type: "pageBuilder",
      title: "Page Builder"
    }
    // ...and other fields
  ]
};

Now we'll actually need to create an Array field for pageBuilder.

// schemas/objects/pageBuilder.js
export default {
  name: "pageBuilder",
  type: "array",
  title: "Page Builder",
  of: [
    {
      type: "pageBuilderContent",
      title: "Content"
    },
    {
      type: "pageBuilderColumns",
      title: "Columns"
    }
  ]
};

And create our pageBuilderContent, pageBuilderColumns fields as well. We're keeping these super basic for the purpose of this tutorial.

// schemas/objects/pageBuilderContent.js
export default {
  name: "pageBuilderContent",
  type: "object",
  title: "Content",
  fields: [
    { name: "title", type: "string", title: "Title" },
    { name: "body", type: "bodyPortableText", title: "Body" },
    { name: "image", type: "mainImage", title: "Image" }
  ]
};
// schemas/objects/pageBuilderColumns.js
export default {
  name: "pageBuilderColumns",
  title: "Columns",
  type: "object",
  fields: [
    {
      name: "columns",
      title: "Columns",
      type: "array",
      of: [{ name: "column", title: "Column", type: "column" }]
    }
  ]
};

And because our column fields are anonymous objects, they'll need hoisting too!

// schemas/objects/column.js
export default {
  name: "column",
  title: "Column",
  type: "object",
  fields: [
    { name: "title", title: "Title", type: "string" },
    { name: "body", title: "Body", type: "bodyPortableText" }
  ]
};

Lastly we need to make Sanity aware of everything we've just done. Like all custom field 'types' you'll need to register all them inside your schema.js.

// schemas/schema.js

// ...all other imports
import page from "./documents/page";
import pageBuilder from "./objects/pageBuilder";
import pageBuilderContent from "./objects/pageBuilderContent";
import pageBuilderColumns from "./objects/pageBuilderColumns";
import column from "./objects/column";

export default createSchema({
  name: "default",
  types: schemaTypes.concat([
    // ...all other documents
    page,

    // ...all other objects
    pageBuilder,
    pageBuilderContent,
    pageBuilderColumns,
    column
  ])
});

Phew!

All going well we've now got a Page document in our Sanity Studio, and a Page Builder Array which can include either a Content or Columns block. Like this!

Sanity Page Builder

Check out those fancy previews. I've left that code out of the examples above but they're in the repo and make a great difference to the editing experience for your blocks.

Don't forget to sanity graphql deploy after each time you make schema changes, so Gatsby can see all your latest fields.

Setup in Gatsby

If you thought setting up Sanity schema was verbose, you're not going to like querying it in GraphQL. Again we need to define every single field in every block in our Page Builder. We'll also need to grab the _raw fields for any Portable Text fields we've used, more on that later.

To maintain your sanity write your GraphQL query in a fragment which you can store anywhere in your Gatsy site and it'll find it.

Here's the GraphQL for my page being generated by gatsby-node.js...

// src/templates/page.js
export const query = graphql`
  query PagePageQuery($id: String!) {
    sanityPage(id: { eq: $id }) {
      ...PageBuilder
    }
  }
`;

...which is calling a fragment that looks like the below.

In the query:

  • Each 'block' must query both the _key and _type fields
  • We query _rawPageBuilder outside pageBuilder to populate any PortableText inside each block
  • And because of that, we don't need to query body inside any block
// src/fragments/pageBuilderFragment.js
import { graphql } from "gatsby";

export const query = graphql`
  fragment PageBuilder on SanityPage {
    _rawPageBuilder(resolveReferences: { maxDepth: 10 })
    pageBuilder {
      ... on SanityPageBuilderContent {
        _key
        _type
        title
        image {
          alt
          asset {
            fluid(maxWidth: 800) {
              ...GatsbySanityImageFluid
            }
          }
        }
      }
      ... on SanityPageBuilderColumns {
        _key
        _type
        column {
          title
        }
      }
    }
  }
`;

Here's the magic, dynamically loading React components from the pageBuilder array!

We create a <PageBuilder /> component, and a component for each Block.

The top level component maps over the 'blocks' in our array, and selectively load the correct component using React.createElement() based on the _type in our query. It will also pass along the correct _raw content for this specific block.

Page Builder Component

// src/components/pageBuilder.js
import React from "react";
import PageBuilderContent from "./pageBuilderContent";
import PageBuilderColumns from "./pageBuilderColumns";

function PageBuilder(props) {
  const { type, pageBuilder, _rawPageBuilder } = props;

  // Load the right component, based on the _type from Sanity
  const Components = {
    pageBuilderContent: PageBuilderContent,
    pageBuilderColumns: PageBuilderColumns
  };

  // 'raw' content needs to be passed in for the PortableText Component
  return pageBuilder.map((block, index) => {
    if (Components[block._type]) {
      return React.createElement(Components[block._type], {
        key: block._key,
        block: block,
        type,
        raw: _rawPageBuilder[index]
      });
    }
  });
}

export default PageBuilder;

Here's how we pass in props:

// src/templates/page.js
<PageBuilder pageBuilder={pageBuilder} _rawPageBuilder={_rawPageBuilder} />

To make this work you'll still need your components for each block. Here's our overly simplified <PageBuilderContent /> component.

// src/components/pageBuilderContent.js
import React from "react";
import Img from "gatsby-image";
import PortableText from "./portableText";

const PageBuilderContent = ({ block, raw, index }) => {
  const { image, title } = block;

  return (
    <section>
      <Img fluid={image.asset.fluid} alt={image.alt} />
      <h1>{title}</h1>
      <PortableText blocks={raw.body} />
    </section>
  );
};

export default PageBuilderContent;

Using the above as an example, you should be able to work out how the Columns block might work as well.

Finishing touches

Simple, right? Ha! The ideas behind all this are really straight forward, there's just quite a number of steps involved to get setup.

It's great how customisable Sanity is but it's also incredibly verbose. We didn't even do nice previews for our blocks!

Be sure to set those up, and a live preview, to make content editing more intuitive.