The Complete Guide to Build a Full Blown Multilanguage Website with Gatsby.js

Contents

This guide is for beginners and professionals who want to build a full-blown multilanguage website using Gatsby.js.

With this step by step guide, you will get a Gatsby website using an Storyblok's api for the multilanguage content and true live preview.

You can clone the code of the tutorial at github.com/storyblok/gatsby-storyblok-boilerplate

Environment setup

Requirements

  • Basic understanding of Gatsby.js

  • Gatsby.js & their CLI

  • NodeJS

  • NPM

  • An account on Storyblok.com to manage content

If not done yet install NodeJs, NPM and the Gatsby.js CLI with npm install --global gatsby-cli. We will start with initializing project with the Gatsby.js starter template.

$ gatsby new my-multilanguage-website
$ cd my-multilanguage-website

Add the Storyblok plugin

Before we run Gatsby we install the source plugin of Storyblok. The Gatsby source plugin makes Storyblok’s content available in GraphQL. For the editing interface we also need to install the Storyblok's React module storyblok-react.

console

$ npm install --save gatsby-source-storyblok storyblok-react

Now you should copy the preview token of a new empty Storyblok space to your clipboard.

Add the config for the Storyblok’s source plugin in the file gatsby-config.js and exchange YOUR_PREVIEW_TOKEN with the preview token of your space.

gatsby-config.js

module.exports = {
  siteMetadata: {
    title: 'Gatsby Default Starter',
  },
  plugins: [
    {
      resolve: 'gatsby-source-storyblok',
      options: {
        accessToken: 'YOUR_PREVIEW_TOKEN',
        homeSlug: 'home',
        version: 'draft'
      }
    },
    ...
  ]
}

Now let's start the Gatsby.js server on port 8000 to see if everything is working. Opening localhost:8000 in your browser should show a welcome message.

console

$ gatsby develop

Creating the editor page

For the Storyblok’s true live preview feature we create a page that acts as a container for all editable content.

Add the file components.js to the components folder with following content and then create the editor.js file below.

src/components/components.js

export default {}

src/pages/editor.js

import React from 'react'
import Components from '../components/components.js'
import SbEditable from 'storyblok-react'
import config from '../../gatsby-config'

const sbConfigs = config.plugins.filter((item) => {
  return item.resolve === 'gatsby-source-storyblok'
})
const sbConfig = sbConfigs.length > 0 ? sbConfigs[0] : {}

const loadStoryblokBridge = function(cb) {
  let script = document.createElement('script')
  script.type = 'text/javascript'
  script.src = `//app.storyblok.com/f/storyblok-latest.js?t=${sbConfig.options.accessToken}`
  script.onload = cb
  document.getElementsByTagName('head')[0].appendChild(script)
}

const getParam = function(val) {
  var result = ''
  var tmp = []

  window.location.search
    .substr(1)
    .split('&')
    .forEach(function (item) {
      tmp = item.split('=')
      if (tmp[0] === val) {
        result = decodeURIComponent(tmp[1])
      }
    })

  return result
}

class StoryblokEntry extends React.Component {
  constructor(props) {
    super(props)
    this.state = {story: null}
  }

  componentDidMount() {
    loadStoryblokBridge(() => { this.initStoryblokEvents() })
  }

  loadStory() {
    window.storyblok.get({
      slug: window.storyblok.getParam('path'),
      version: 'draft',
      resolve_relations: sbConfig.options.resolveRelations || []
    }, (data) => {
      this.setState({story: data.story})
    })
  }

  initStoryblokEvents() {
    this.loadStory()

    let sb = window.storyblok

    sb.on(['change', 'published'], (payload) => {
      this.loadStory()
    })

    sb.on('input', (payload) => {
      if (this.state.story && payload.story.id === this.state.story.id) {
        payload.story.content = sb.addComments(payload.story.content, payload.story.id)
        this.setState({story: payload.story})
      }
    })

    sb.pingEditor(() => {
      if (sb.inEditor) {
        sb.enterEditmode()
      }
    })
  }

  render() {
    if (this.state.story == null) {
      return (<div></div>)
    }

    let content = this.state.story.content

    return (
      <SbEditable content={content}>
      <div>
        {React.createElement(Components(content.component), {key: content._uid, blok: content})}
      </div>
      </SbEditable>
    )
  }
}

export default StoryblokEntry

The next step is to configure the preview domain inside Storyblok. After you have created a new empty space click on the first content item and scroll down to the domain setting to set following value:

localhost:8000/editor?path=

Creating the homepage components

To render the full homepage we will need to create some components.

The components Grid, Teaser and Feature are subcomponents of the component Page which is a Storyblok content type. For simplicity we decided to organize everything in components. If you want to create a blog article content type you can just add a BlogArticle component to the file components.js and you’re done.

Open the file src/components/components.js and add the four basic components as following.

src/components/components.js

import Page from './page'
import Grid from './grid'
import Teaser from './teaser'
import Feature from './feature'
import ComponentNotFound from './component_not_found'

const ComponentList = {
  page: Page,
  grid: Grid,
  teaser: Teaser,
  feature: Feature
}

const Components = (type) => {
  if (typeof ComponentList[type] === 'undefined') {
    return ComponentNotFound
  }
  return ComponentList[type]
}

export default Components

Add the not found component

src/components/component_not_found.js

import React from 'react'

const ComponentNotFound = (props) => (
  <div>
    Component {props.blok.component} is not defined. Add it to components.js
  </div>
)

export default ComponentNotFound

Add the feature component

src/components/feature.js

import React from 'react'
import SbEditable from 'storyblok-react'

const Feature = (props) => (
  <SbEditable content={props.blok}>
    <div className="col-4">
      <h2>{props.blok.name}</h2>
    </div>
  </SbEditable>
)

export default Feature

Add the grid component

src/components/grid.js

import React from 'react'
import Components from './components.js';
import SbEditable from 'storyblok-react'

const Grid = (props) => (
  <SbEditable content={props.blok}>
    <div className="container">
      <div className="row">
        {props.blok.columns.map((blok) =>
          React.createElement(Components(blok.component), {key: blok._uid, blok: blok})
        )}
      </div>
    </div>
  </SbEditable>
)

export default Grid

Add the page component

src/components/page.js

import React from 'react'
import Components from './components.js';

const Page = (props) => (
  <div>
    {props.blok.body && props.blok.body.map((blok) => React.createElement(Components(blok.component), {key: blok._uid, blok: blok}))}
  </div>
)

export default Page

Add the teaser component

src/components/teaser.js

import React from 'react'
import Link from 'gatsby-link'
import SbEditable from 'storyblok-react'

const Teaser = (props) => (
  <SbEditable content={props.blok}>
    <div className="jumbotron jumbotron-fluid">
      <div className="container">
        <h1 className="display-4">{ props.blok.headline }</h1>
        <p className="lead">This is a modified jumbotron that occupies the entire horizontal space of its parent.</p>
        <p className="lead">
          <Link className="btn btn-primary" to={'/blog/'}>
            Blog Posts
          </Link>
        </p>
      </div>
    </div>
  </SbEditable>
)

export default Teaser

Create the html container

To add some basic stylings we’ll add bootstrap CSS by creating the file html.js in the src folder as following.

src/html.js

import React from 'react';

export default class HTML extends React.Component {
  render() {
    return (
      <html lang="en">
        <head>
          <meta charSet="utf-8" />
          <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
          />
          {this.props.headComponents}
          <link
            rel="stylesheet"
            href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css"
          />
        </head>
        <body>
          <div
            id="___gatsby"
            dangerouslySetInnerHTML={{ __html: this.props.body }}
          />
          {this.props.postBodyComponents}
        </body>
      </html>
    );
  }
}

Testing

After creating all components you should already be able to manage content using the true live preview in Storyblok. Go to the Storyblok editor and create a new page and insert a teaser in the compose mode to test if everything is working.

Setup the static file generation

In the last steps we made our content editable but for the static file generation we need two more things: A template and a gatsby-node.js file which generates pages dynamically.

Create the template

Create the folder src/templates and place the file storyblok-entry.js with following code inside.

src/templates/storyblok-entry.js

import React from 'react'
import Components from '../components/components.js'

class StoryblokEntry extends React.Component {
  static getDerivedStateFromProps(props, state) {
    if (state.story.uuid === props.pageContext.story.uuid) {
      return null
    }

    return StoryblokEntry.prepareStory(props)
  }

  static prepareStory(props) {
    const story = Object.assign({}, props.pageContext.story)
    story.content = JSON.parse(story.content)
    
    return { story }
  }

  constructor(props) {
    super(props)

    this.state = StoryblokEntry.prepareStory(props)
  }

  render() {
    let content = this.state.story.content

    return (
      <div>
        {React.createElement(Components(content.component), {key: content._uid, blok: content})}
      </div>
    )
  }
}

export default StoryblokEntry

Dynamically generate pages

Open the file gatsby-node.js and add following code for the dynamic generation of pages. The call to createPage will create a page for each content item you have created in Storyblok when executing gatsby build.

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/storyblok-entry.js')

    resolve(
      graphql(
        `{
          allStoryblokEntry {
            edges {
              node {
                id
                name
                created_at
                uuid
                slug
                full_slug
                content
                is_startpage
                parent_id
                group_id
              }
            }
          }
        }`
      ).then(result => {
        if (result.errors) {
          console.log(result.errors)
          reject(result.errors)
        }

        const entries = result.data.allStoryblokEntry.edges
        entries.forEach((entry, index) => {
          let pagePath = entry.node.full_slug == 'home' ? '' : `${entry.node.full_slug}/`

          createPage({
            path: `/${pagePath}`,
            component: storyblokEntry,
            context: {
              story: entry.node
            }
          })
        })
      })
    )
  })
}

Important: Remove the file src/pages/index.js in order to generate Storyblok’s homepage instead of the one of the Gatsby boilerplate.

Try out generating the pages executing a gatsby build process in the command line:

$ gatsby build

Deploy only published content

To generate only pages that have the status “published” we’ll need to adapt the file gatsby-config.js and add a check on the environment type. When the NODE_ENV is set to production we’ll use the published version and otherwise the draft version.

module.exports = {
  ...
  plugins: [
    {
      resolve: 'gatsby-source-storyblok',
      options: {
        accessToken: 'bJQb8KcUXW4NJ35XJFwGuwtt',
        homeSlug: 'home',
        version: process.env.NODE_ENV == 'production' ? 'published' : 'draft'
      }
    },
    ...

In the next step I’ll explain how to publish content using Storyblok’s webhooks.

Publishing content

To publish content to your website you need to trigger a Gatsby build. To give the editor this ability you can use Storyblok’s published webhook feature to call a url when clicking on the publish button of a content item or use Storyblok’s free tasks app. Netlify, a Lambda function or a continuous integration tool can then handle the post request and generate the static website with Storyblok’s content.

Install the “Task-Manager” from the Apps section and create a task with the webhook url from Netlify or other CI tools.

Multilanguage content

There are a two options to manage multi-language and multi-country content in Storyblok. Field level translation and a single content tree or language folders for multiple content trees. Choosing field level translations is a good decision if you have most of your content translated. Consider using multiple content trees if the content is different in every language.

In this tutorial we'll setup field level translations with a single content tree.

Setup field level translation

First you need to add the language(s) you want to translate. In our example we added German and our default language will be English.

Storyblok Field level translation

Now you need to go to the fields of every component and define them as translatable clicking the "Translatable" checkbox in the schema configuration. Make sure to not define the blocks field type as translatable as this will disable the translation functionality of the inner component fields.

With the Storyblok's Gatsby source plugin version >=0.2.3 you can now just rerun the build process and it will generate all translated pages automatically.

Create a multi-language navigation

To generate a multi-language navigation you need to make some adaptions to your Gatsby project.

First you need to create a new content type. In our case we named it global_navi and created a content item with the slug global-navi.

The global_navi content type has a "Blocks" field type named nav_items with nav_item as sub components. The nav_item has a link and a name field.

Global Navigation

Now we add the code required to fetch the navigation in Gatsby.

Start by editing the file gatsby-node.js

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/storyblok-entry.js')

    resolve(
      graphql(
        `{
          stories: allStoryblokEntry {
            edges {
              node {
                id
                name
                created_at
                uuid
                slug
                field_component
                full_slug
                content
                is_startpage
                parent_id
                group_id
              }
            }
          }
        }`
      ).then(result => {
        if (result.errors) {
          console.log(result.errors)
          reject(result.errors)
        }

        const entries = result.data.stories.edges
        const contents = entries.filter((entry) => {
          return entry.node.field_component != 'global_navi'
        })

        contents.forEach((entry, index) => {
          const pagePath = entry.node.full_slug == 'home' ? '' : `${entry.node.full_slug}/`
          const globalNavi = entries.filter((globalEntry) => {
            return globalEntry.node.field_component == 'global_navi' && globalEntry.node.lang == entry.node.lang
          })
          if (!globalNavi.length) {
            throw new Error('The global navigation item has not been found. Please create a content item with the content type global_navi in Storyblok.')
          }

          createPage({
            path: `/${pagePath}`,
            component: storyblokEntry,
            context: {
              globalNavi: globalNavi[0].node,
              story: entry.node
            }
          })
        })
      })
    )
  })
}

Add the nav_item component to the file component.js.

src/components/components.js

import Page from './page'
import Grid from './grid'
import Teaser from './teaser'
import Feature from './feature'
import NavItem from './nav_item'
import ComponentNotFound from './component_not_found'

const ComponentList = {
  page: Page,
  grid: Grid,
  teaser: Teaser,
  feature: Feature,
  nav_item: NavItem
}

const Components = (type) => {
  if (typeof ComponentList[type] === 'undefined') {
    return ComponentNotFound
  }
  return ComponentList[type]
}

export default Components

Add the component nav_item.js:

src/components/nav_item.js

import React from 'react'
import Link from 'gatsby-link'
import SbEditable from 'storyblok-react'

const NavItem = (props) => (
  <SbEditable content={props.blok}>
    <li className="nav-item active">
      <Link className="nav-link" to={'/' + (props.blok.link.cached_url === 'home' ? '' : props.blok.link.cached_url)}>
        {props.blok.name}
      </Link>
    </li>
  </SbEditable>
)

export default NavItem

Add the component navi.js:

src/components/navi.js

import React from 'react'
import Components from './components.js';

const Navi = (props) => (
  <nav className="navbar navbar-expand navbar-light bg-light">
    <span className="navbar-brand">Navi</span>
    <div className="collapse navbar-collapse" id="navbarNav">
      <ul className="navbar-nav">
        {props.blok.nav_items && props.blok.nav_items.map((blok) => React.createElement(Components(blok.component), {key: blok._uid, blok: blok}))}
      </ul>
    </div>
  </nav>
)

export default Navi

Adapt the file editor.js to include the Navi component and get the global navigation content item with the function loadGlovalNavi.

src/pages/editor.js

import React from 'react'
import Components from '../components/components.js'
import SbEditable from 'storyblok-react'
import config from '../../gatsby-config'
import Navi from '../components/navi.js'

const sbConfigs = config.plugins.filter((item) => {
  return item.resolve === 'gatsby-source-storyblok'
})
const sbConfig = sbConfigs.length > 0 ? sbConfigs[0] : {}

const loadStoryblokBridge = function(cb) {
  let script = document.createElement('script')
  script.type = 'text/javascript'
  script.src = `//app.storyblok.com/f/storyblok-latest.js?t=${sbConfig.options.accessToken}`
  script.onload = cb
  document.getElementsByTagName('head')[0].appendChild(script)
}

class StoryblokEntry extends React.Component {
  constructor(props) {
    super(props)
    this.state = {story: null, globalNavi: {content: {}}}
  }

  componentDidMount() {
    loadStoryblokBridge(() => { this.initStoryblokEvents() })
  }

  loadStory() {
    window.storyblok.get({
      slug: window.storyblok.getParam('path'), 
      version: 'draft',
      resolve_relations: sbConfig.options.resolveRelations || []
    }, (data) => {
      this.setState({story: data.story})
      this.loadGlovalNavi(data.story.lang)
    })
  }

  loadGlovalNavi(lang) {
    const language = lang === 'default' ? '' : lang + '/'
    window.storyblok.get({
      slug: `${language}global-navi`, 
      version: 'draft'
    }, (data) => {
      this.setState({globalNavi: data.story})
    })
  }

  initStoryblokEvents() {
    this.loadStory()

    let sb = window.storyblok

    sb.on(['change', 'published'], (payload) => {
      this.loadStory()
    })

    sb.on('input', (payload) => {
      if (this.state.story && payload.story.id === this.state.story.id) {
        payload.story.content = sb.addComments(payload.story.content, payload.story.id)
        this.setState({story: payload.story})
      }
    })

    sb.pingEditor(() => {
      if (sb.inEditor) {
        sb.enterEditmode()
      }
    })
  }

  render() {
    if (this.state.story == null) {
      return (<div></div>)
    }

    let content = this.state.story.content
    let globalNavi = this.state.globalNavi.content

    return (
      <SbEditable content={content}>
      <div>
        <Navi blok={globalNavi}></Navi>
        {React.createElement(Components(content.component), {key: content._uid, blok: content})}
      </div>
      </SbEditable>
    )
  }
}

export default StoryblokEntry

Load and pass down the global navigation content to the Nav component in the template storyblok-entry.js.

src/templates/storyblok-entry.js

import React from 'react'
import Components from '../components/components.js'
import Navi from '../components/navi.js'

class StoryblokEntry extends React.Component {
  static getDerivedStateFromProps(props, state) {
    if (state.story.uuid === props.pageContext.story.uuid) {
      return null
    }

    return StoryblokEntry.prepareStory(props)
  }

  static prepareStory(props) {
    const story = Object.assign({}, props.pageContext.story)
    const globalNavi = Object.assign({}, props.pageContext.globalNavi)
    story.content = JSON.parse(story.content)
    globalNavi.content = JSON.parse(globalNavi.content)
    
    return { story, globalNavi }
  }

  constructor(props) {
    super(props)

    this.state = StoryblokEntry.prepareStory(props)
  }

  render() {
    let content = this.state.story.content
    let globalNavi = this.state.globalNavi.content

    return (
      <div>
        <Navi blok={globalNavi}></Navi>
        {React.createElement(Components(content.component), {key: content._uid, blok: content})}
      </div>
    )
  }
}

export default StoryblokEntry

Multi-language setup complete!

Congratulations! You now have a multi-language Gatsby website with global navigation and live preview.

Conclusion

Gatsby.js and Storyblok makes it super easy for your content editors to manage content. With this setup Storyblok’s true live preview can be mounted on your statically generated website so you don’t even need to run a server in the background.

ResourceLink
Github repository of this tutorialgithub.com/storyblok/gatsby-storyblok-boilerplate
Gatsby.jsgatsbyjs.org
React.jsreactjs.org
Storyblok AppStoryblok

About the author

Alexander Feiglstorfer

Alexander Feiglstorfer

Passionate developer and always in search of the most effective way to resolve a problem. After working 13 years for agencies and SaaS companies using almost every CMS out there he founded Storyblok to solve the problem of being forced to a technology, proprietary template languages and the plugin hell of monolithic systems.


More to read...