Add a headless CMS to Gatsby.js in 5 minutes

Contents

In this short tutorial, we will explore how to integrate Storyblok into a Gatsby.js site and enable the live preview in the Visual Editor. We will use the gatsby-source-storyblok plugin to load our data from Storyblok and enable the Storyblok Bridge to preview our changes.

You can find all the code for this tutorial and commits in our gatsby-storyblok-boilerplate repo. You can also follow the video below, which guides you through all the steps.

Requirements

To follow this tutorial there are the following requirements:

  • Basic understanding of Gatsby.js and React

  • Node, yarn (or npm) and Gatsby installed

  • An account on Storyblok to manage content

  • A new Storyblok space

WARN::

The project in this article was developed using the following versions of these technologies:

  • Gatsby v3.4.1
  • Nodejs v12.18.0
  • Npm v6.14.9

Keep in mind that these versions may be slightly behind the latest ones.

Setup the project

Let's start by creating a new Gatsby.js application.

gatsby new storyblok-gatsby-boilerplate https://github.com/gatsbyjs/gatsby-starter-default

Next, we need to install the packages gatsby-source-storyblok, storyblok-react, and storyblok-js-client.

cd storyblok-gatsby-boilerplate
npm install --save storyblok-react gatsby-source-storyblok storyblok-js-client

Then let's start the development server.

npm run develop

Open your browser in http://localhost:8000. You should see the following screen.

Gatsby.js default starter template website

Adding the development server to Storyblok

Create a new space and click on Settings {1} in the sidebar. Under General add your http://localhost:8000/ server as the default environment {2}.

Default Url

Setting the real path

Next, click on Content and open the Home story. Since Storyblok will automatically attach the slug for any story entry, we need to set the real path on our home entry, since the slug is home. Click on Config {1} and enter / {2} as the Real Path. Now you should see your development server inside of Storyblok.

Real Path

Using gatsby-source-storyblok

To connect to the Storyblok API, we already installed the gatsby-source-storyblok plugin. Now it's time to add it to our project. Add the following to your gatsby-config.js file (Line 32).

gatsby-config.js
module.exports = {
  siteMetadata: {
    title: `Gatsby Default Starter`,
    description: `Kick off your next, great Gatsby project with this default starter. This barebones starter ships with the main Gatsby configuration files you might need.`,
    author: `@gatsbyjs`,
  },
  plugins: [
    `gatsby-plugin-react-helmet`,
    `gatsby-plugin-image`,
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `images`,
        path: `${__dirname}/src/images`,
      },
    },
    `gatsby-transformer-sharp`,
    `gatsby-plugin-sharp`,
    {
      resolve: `gatsby-plugin-manifest`,
      options: {
        name: `gatsby-starter-default`,
        short_name: `starter`,
        start_url: `/`,
        background_color: `#663399`,
        theme_color: `#663399`,
        display: `minimal-ui`,
        icon: `src/images/gatsby-icon.png`, // This path is relative to the root of the site.
      },
    },
    `gatsby-plugin-gatsby-cloud`,
    {
      resolve: 'gatsby-source-storyblok',
      options: {
        accessToken: 'YOUR-PREVIEW-TOKEN',
        version: 'draft',
        // languages: ['de', 'at'] // Optional parameter. Omission will retrieve all languages by default.
      }
    }
    // this (optional) plugin enables Progressive Web App + Offline functionality
    // To learn more, visit: https://gatsby.dev/offline
    // `gatsby-plugin-offline`,
  ],
}

Retrieve your preview token {3} from your space Settings {1} under API-Keys {2}. Add the token to your source plugin in gatsby-config.js as the accessToken on Line 35.

Preview Token

Using the GraphiQL Explorer

If you're working with Gatsby.js, whenever you start the development, it will also start the GraphiQL Explorer on the following URL: http://localhost:8000/___graphql. As soon as you added the source plugin, you will be able to explore the Storyblok API endpoints there. Read the following tutorial to learn more about the explorer.

It's useful to enable the refresh feature, described in the how to refresh content tutorial. You can do that by pretending the develop command in your package.json file:

  "scripts": {
     ...
    "develop": "ENABLE_GATSBY_REFRESH_ENDPOINT=true gatsby develop",
     ...
}

If you're connected to the Storyblok API through the source plugin, you should be able to see multiple query options in the Explorer. You can select for example allStoryblokEntry {1} and get specific information from Storyblok like the full_slug {2} of a story. By clicking around the explorer, your query will automatically be built on the right {3}. If you enabled the refresh endpoint, you will also see a Refresh Data {4} button to reload the data. Finally, if you got the query you want, you can click the Code Exporter button {5} to get the Javascript code you need inside of Gatsby.

GrapiQL Explorer

Using gatsby-source-storyblok on a page

Now that we've created all the components, let's update pages/index.js, our homepage, with the code below. We're loading our home content through the gatsby-source-storyblok plugin. Keep in mind that the Gatsby.js source plugin is loading content at build time, so whenever you change the content inside of Storyblok, you will need to restart your server or use the refresh data feature described above. Read this article to understand the difference between build and client time in Gatsby.

pages/index.js
import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"
import { graphql } from 'gatsby'
import SbEditable from 'storyblok-react'

import Layout from "../components/layout"
import Seo from "../components/seo"
import DynamicComponent from "../components/dynamicComponent"

const IndexPage = ({ data }) => { 
  let story = data.storyblokEntry
  story.content = JSON.parse(story.content)


  return (
  <Layout>
    <Seo title="Home" />
    <h1>{ story.name }</h1>
    <StaticImage
      src="../images/gatsby-astronaut.png"
      width={300}
      quality={95}
      formats={["AUTO", "WEBP", "AVIF"]}
      alt="A Gatsby astronaut"
      style={{ marginBottom: `1.45rem` }}
    />
    <p>
      <Link to="/page-2/">Go to page 2</Link> <br />
      <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
    </p>
  </Layout>
)}

export default IndexPage

export const query = graphql`
  query HomeQuery {
    storyblokEntry(full_slug: {eq: "home"}) {
      content
      name
    }
  }
`

Since the Storyblok GraphQL API returns the content as a string, we will need to parse the story content with the following line: story.content = JSON.parse(story.content) on Line 13 above.

Adding Dynamic Components

Now that we kickstarted our project and have a simple connection to Storyblok, we want to load components dynamically. We will create two files in the component folder: dynamicComponent.js and teaser.js

components/dynamicComponent.js
import SbEditable from 'storyblok-react'
import Teaser from './teaser'
import React from "react"

const Components = {
  'teaser': Teaser,
}

const DynamicComponent = ({ blok }) => {
  if (typeof Components[blok.component] !== 'undefined') {
    const Component = Components[blok.component]
    return (<SbEditable content={blok}><Component blok={blok} /></SbEditable>)
  }
  return (<p>The component <strong>{blok.component}</strong> has not been created yet.</p>)
}

export default DynamicComponent

DynamicComponent is a wrapper around our components to load the correct components and enable live editing, when we click a component. You need to add all components you want to dynamically load to this loader.

components/teaser.js
import * as React from "react"

const Teaser = ({ blok }) => (
    <div>
      <h2>
          { blok.headline }
      </h2>
      <p>
          { blok.intro }
      </p>
    </div>
)

export default Teaser

Teaser is a component that is dynamically loaded through the dynamic component loader. The teaser component is a component, that already exists in your Storyblok space, whenever you create a new space.

Loading Dynamic Components

Finally, we need to add the last part to our pages/index.js file to display our components.

pages/index.js
import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"
import { graphql } from 'gatsby'
import SbEditable from 'storyblok-react'

import Layout from "../components/layout"
import Seo from "../components/seo"
import DynamicComponent from "../components/dynamicComponent"

const IndexPage = ({ data }) => { 
  let story = data.storyblokEntry
  story.content = JSON.parse(story.content)

  const components = story.content.body.map(blok => {
    return (<DynamicComponent blok={blok} key={blok._uid} />)
  })

  return (
  <Layout>
    <Seo title="Home" />
    <h1>{ story.content.title }</h1>
    { components }
    <StaticImage
      src="../images/gatsby-astronaut.png"
      width={300}
      quality={95}
      formats={["AUTO", "WEBP", "AVIF"]}
      alt="A Gatsby astronaut"
      style={{ marginBottom: `1.45rem` }}
    />
    <p>
      <Link to="/page-2/">Go to page 2</Link> <br />
      <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
    </p>
  </Layout>
)}

export default IndexPage

export const query = graphql`
  query HomeQuery {
    storyblokEntry(full_slug: {eq: "home"}) {
      content
      name
    }
  }
`

On Line 15 we map over all the components in our page body to display them dynamically.

If you added a Title field to the page component {1} by defining the schema (see video around minute 19:00), you should be able to see the title. Then the components should be loaded {2} automatically. If the component is not defined in your component/dynamicComponent.js file, you will see the fallback text {3}.

Content loaded
Hint:

You can check the commit feat: add storyblok source plugin to index and feat: add dynamic component for the changes.

Enabling the Visual Editor & Live Preview

So far we loaded our content from Storyblok, but we aren't able to directly select the different components. To enable Storyblok's Visual Editor, we need to connect the Storyblok Bridge. For this tutorial, we will already use the new Storyblok Bridge Version 2. After loading the bridge, we will need to add a React hook to enable live updating of the story content.

Adding the Storyblok Bridge

To do that we have to add a specific <script> tag to the end of our document, whenever we want to enable it. This is mostly the case when you're inside the Storyblok editor. By wrapping the page in a SbEditablecomponent, we also make the page fields like the title clickable.

<script src="//app.storyblok.com/f/storyblok-v2-latest.js" type="text/javascript" id="storyblokBridge">
</script>

Inside our lib/storyblok.js file, add the following code after the client. In Line 12, we're creating a custom React hook called useStoryblok.

lib/storyblok.js
import { useEffect, useState } from "react"

export default function useStoryblok(originalStory, location) {
    let [story, setStory] = useState(originalStory)

    if(story && typeof story.content === "string"){
      story.content = JSON.parse(story.content)
    }
    
    // see https://www.storyblok.com/docs/Guides/storyblok-latest-js
    function initEventListeners() {
      const { StoryblokBridge } = window

      if (typeof StoryblokBridge !== 'undefined') {
        const storyblokInstance = new StoryblokBridge()

        storyblokInstance.on(['published', 'change'], (event) => {
          // reloade project on save an publish
          window.location.reload(true)
        })  
    
        storyblokInstance.on(['input'], (event) => {
          // live updates when editing
          if (event.story._uid === story._uid) {
            setStory(event.story)
          }
        }) 

        storyblokInstance.on(['enterEditmode'], (event) => {
          // loading the story with the client
        }) 
      }
    }

    function addBridge(callback) {
        // check if the script is already present
        const existingScript = document.getElementById("storyblokBridge");
        if (!existingScript) {
          const script = document.createElement("script");
          script.src = `//app.storyblok.com/f/storyblok-v2-latest.js`;
          script.id = "storyblokBridge";
          document.body.appendChild(script);
          script.onload = () => {
            // call a function once the bridge is loaded
            callback()
          };
        } else {
            callback();
        }
    }

    useEffect(() => {
      // load bridge only inside the storyblok editor
      if(location.search.includes("_storyblok")) {
        // first load the bridge and then attach the events
        addBridge(initEventListeners)
      }
    }, []) // it's important to run the effect only once to avoid multiple event attachment

    return story;
}

Inside this hook, we have a function addBridge (Line 39), which basically just adds the script tag, if it's not already present. Once the loading of the bridge is completed (Line 58), it will call the initEventListeners function (Line 17) to enable input (Line 25) and published and change events (Line 22) inside Storyblok. We could also make use of the enterEditmode event to load the draft story when the editor is open.

Finally, we need to load this hook in our pages/index.js file on Line 13. We will also move the parsing of the story content to the lib/storyblok.js file.

pages/index.js
import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"
import { graphql } from 'gatsby'
import SbEditable from 'storyblok-react'
 
import Layout from "../components/layout"
import Seo from "../components/seo"
import DynamicComponent from "../components/dynamicComponent"
import useStoryblok from "../lib/storyblok"
 
const IndexPage = ({ data, location }) => { 
  let story = data.storyblokEntry
  story = useStoryblok(story, location)
 
  const components = story.content.body.map(blok => {
    return (<DynamicComponent blok={blok} key={blok._uid} />)
  })
 
  return (
  <Layout>
    <SbEditable content={story.content}>
      <Seo title="Home" />
      <h1>{ story.content.title }</h1>
      { components }
        <StaticImage
          src="../images/gatsby-astronaut.png"
          width={300}
          quality={95}
          formats={["AUTO", "WEBP", "AVIF"]}
          alt="A Gatsby astronaut"
          style={{ marginBottom: `1.45rem` }}
        />
        <p>
          <Link to="/page-2/">Go to page 2</Link> <br />
          <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
        </p>
    </SbEditable>
  </Layout>
)}
 
export default IndexPage
 
export const query = graphql`
  query HomeQuery {
    storyblokEntry(full_slug: {eq: "home"}) {
      content
      name
    }
  }
`

If the connection with the Storyblok hook is working, you should be able to select the component directly.

Visual Editor active
Hint:

You can check the commit feat: add storyblok bridge and live updates for the changes.

Using Storyblok JS client

Finally, we can extend our hook to also make use of the JS client to load the draft version of our content when the editor is open. Add the following to the lib/storyblok.js file on Line 3 and Line 42:

lib/storyblok.js
import { useEffect, useState } from "react"
import StoryblokClient from "storyblok-js-client";
import config from '../../gatsby-config'
const sbConfig = config.plugins.find((item) => item.resolve === 'gatsby-source-storyblok')

const Storyblok = new StoryblokClient({
  accessToken: sbConfig.options.accessToken,
  cache: {
    clear: "auto",
    type: "memory",
  },
});

export default function useStoryblok(originalStory, location) {
    let [story, setStory] = useState(originalStory)

    if(story && typeof story.content === "string"){
      story.content = JSON.parse(story.content)
    }
    
    // see https://www.storyblok.com/docs/Guides/storyblok-latest-js
    function initEventListeners() {
      const { StoryblokBridge } = window

      if (typeof StoryblokBridge !== 'undefined') {
        const storyblokInstance = new StoryblokBridge()

        storyblokInstance.on(['published', 'change'], (event) => {
          // reloade project on save an publish
          window.location.reload(true)
        })  
    
        storyblokInstance.on(['input'], (event) => {
          // live updates when editing
          if (event.story._uid === story._uid) {
            setStory(event.story)
          }
        }) 

        storyblokInstance.on(['enterEditmode'], (event) => {
          // loading the draft version on initial view of the page
          Storyblok
            .get(`cdn/stories/${event.storyId}`, {
              version: 'draft',
            })
            .then(({ data }) => {
              if(data.story) {
                setStory(data.story)
              }
            })
            .catch((error) => {
              console.log(error);
            }) 
        }) 
      }
    }

    function addBridge(callback) {
        // check if the script is already present
        const existingScript = document.getElementById("storyblokBridge");
        if (!existingScript) {
          const script = document.createElement("script");
          script.src = `//app.storyblok.com/f/storyblok-v2-latest.js`;
          script.id = "storyblokBridge";
          document.body.appendChild(script);
          script.onload = () => {
            // call a function once the bridge is loaded
            callback()
          };
        } else {
            callback();
        }
    }

    useEffect(() => {
      // load bridge only inside the storyblok editor
      if(location.search.includes("_storyblok")) {
        // first load the bridge and then attach the events
        addBridge(initEventListeners)
      }
    }, []) // it's important to run the effect only once to avoid multiple event attachment

    return story;
}

Using the enterEditmode event to request the draft content from Storyblok with the open story entry.

Automatic Page Generation

In most cases, you would want to automatically generate the pages from the content you have set up in Storyblok. To do that with Gatsby, we can follow this tutorial. Basically, what we need to do is to add a template file: templates/page.js as well as change our gatsby-node.js.

Let's start by creating the template, similar to our pages/index.js. Create a new folder templates with a file page.js:

templates/page.js
import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"
import { graphql } from 'gatsby'

import Layout from "../components/layout"
import Seo from "../components/seo"
import DynamicComponent from "../components/dynamicComponent"
import useStoryblok from "../lib/storyblok"

const Page = ({ pageContext, location }) => { 
  let story = pageContext.story
  story = useStoryblok(story, location)

  const components = story.content.body.map(blok => {
    return (<DynamicComponent blok={blok} key={blok._uid} />)
  })

  return (
  <Layout>
    <Seo title="Home" />
    <h1>{ story.content.title }</h1>
    { components }
    <StaticImage
      src="../images/gatsby-astronaut.png"
      width={300}
      quality={95}
      formats={["AUTO", "WEBP", "AVIF"]}
      alt="A Gatsby astronaut"
      style={{ marginBottom: `1.45rem` }}
    />
    <p>
      <Link to="/page-2/">Go to page 2</Link> <br />
      <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
    </p>
  </Layout>
)}

export default Page

The biggest difference to the pages/index.js is that it's not using GraphQl directly on the page, but exposing the story via the pageContext object.

To get that working we also need to add some logic to our gatsby-node.js file.

gatsby-node.js
const path = require('path')

 exports.createPages = ({ graphql, actions }) => {
    const { createPage } = actions

    return new Promise((resolve, reject) => {
        const storyblokEntry = path.resolve('src/templates/page.js')
    
        resolve(
          graphql(
            `{
              stories: allStoryblokEntry(filter: {field_component: {eq: "page"}}) {
                edges {
                  node {
                    id
                    name
                    slug
                    field_component
                    full_slug
                    content
                  }
                }
              }
            }`
          ).then(result => {
            if (result.errors) {
              console.log(result.errors)
              reject(result.errors)
            }
    
            const entries = result.data.stories.edges
    
            entries.forEach((entry) => {
                if(entry.slug !== "home") {
                    const page = {
                        path: `/${entry.node.full_slug}`,
                        component: storyblokEntry,
                        context: {
                            story: entry.node
                        }
                    }
                    createPage(page)
                }
            })
          })
        )
      })
 }

On Line 7, we're loading our template we just created for all pages. On Line 10 we're requesting all stories from Storyblok with the content type Page. On Line 35, we're creating the actual pages, but are skipping the Home story, because we're already loading that one in the index.js file.

When we create a new entry in Storyblok, saving it, and restarting the development server (or using the refresh button in the GraphiQL explorer), the new page should now be visible on your localhost.

Hint:

You can check the commit feat: automatic page generation for the changes.

Adding a fallback page

Since the production build, will only have the content and data available, that was available during build time, we need to add a fallback to our 404 page to display Storyblok content via a client-side request. Add the following to the pages/404.js:

pages/404.js
import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"
import SbEditable from 'storyblok-react'

import Layout from "../components/layout"
import Seo from "../components/seo"
import DynamicComponent from "../components/dynamicComponent"
import useStoryblok from "../lib/storyblok"

const NotFoundPage = ({ location }) => { 
  let components = null
  let story = useStoryblok(null, location)

  if(story) {
    components = story.content.body.map(blok => {
      return (<DynamicComponent blok={blok} key={blok._uid} />)
    })
  }

  return (
  <Layout>
    <Seo title="Home" />
    <SbEditable content={story ? story.content : false }>
    <h1>{ story ? story.content.title : 'Not Found' }</h1>
    { components }
    <StaticImage
      src="../images/gatsby-astronaut.png"
      width={300}
      quality={95}
      formats={["AUTO", "WEBP", "AVIF"]}
      alt="A Gatsby astronaut"
      style={{ marginBottom: `1.45rem` }}
    />
    <p>
      <Link to="/page-2/">Go to page 2</Link> <br />
      <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
    </p>
    </SbEditable>
  </Layout>
)}

export default NotFoundPage

We're calling our Storyblok hook with no given story. If we're inside the Storyblok editor, however, we can access the editor story and update the page dynamically on the input event. Which will give us a preview of the page. If you're on the development server, you have to click the Preview custom 404 page button to see this fallback page.

Using Storyblok's GraphQL API

If you want to use our GraphQL API directly instead of the gatsby-source-storyblok plugin, we recommend using the gatsby-source-graphql plugin. This can be useful to directly query the content object instead of a stringified version. Add the following to your gatsby.config.js file:

module.exports = {
  /* Your site config here */
  plugins: [
    ...
    {
      resolve: `gatsby-source-graphql`,
      options: {
        fieldName: `Storyblok`,
        typeName: `storyblok`,
        url: `https://gapi.storyblok.com/v1/api`,
        headers: {
          Token: `YOUR_PREVIEW_TOKEN`,
          Version: `draft`,
        },
      },
    },
  ],
}

Using Gatsby Image

Using the new gatsby-plugin-image through graphql is not supported by the gatsby-source-storyblok plugin nor by the gatsby-source-graphql plugin. However, we can recommend taking a look at the community plugin gatsby-storyblok-image for adding Gatsby Image support outside of GraphQL.

Deploying a Gatsby.js site

It's important to understand that Gatsby.js is a static site generator and should be used as such. The speed and advantage of Gatsby come from statically generated files. So if we update our content, in most cases we need to rebuild our project. To do that not constantly, the most common approach is to rebuild the page whenever we publish. You can do that by adding deploy hooks to netlify or vercel for example. You can find out how to generate a publish webhook in Storyblok in this tutorial.

You can follow the Gatsby.js guides to set up your deployment on your hosting provider.