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

Contents
    Try Storyblok

    Storyblok is the first headless CMS that works for developers & marketers alike.

    This guide is for beginners and professionals who want to build a full-blown multilanguage website using Gatsby.js. You can take a look at the deployed Demo project here.

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

    If you are in a hurry you can download the whole source code of the project at Github github.com/storyblok/gatsbyjs-multilanguage-website.

    Storyblok editing capabilities

    Requirements

    To continue with this tutorial, we don't expect you are an expert web developer, but you should understand a few basic concepts listed under this paragraph. We will guide you through most topics of this tutorial, but if you are beginning with Gatsby.js & Storyblok, you should consider checking out our Getting Started with Gatsby.js Tutorial first.

    Environment setup

    If you haven't done so already, install Node.js, NPM and the Gatsby.js CLI on your machine. Start with running the following shell commands. This will clone our example repository for a multi-language blog.

    $ npm install -g gatsby-cli 
    $ git clone https://github.com/storyblok/gatsbyjs-multilanguage-website.git
    $ cd gatsbyjs-multilanguage-website
    $ npm install
    $ gatsby develop

    Cloning the Storyblok Template

    To get the correct space setup for the Github repository, click the following link to duplicate our example multi-language space: https://app.storyblok.com/#!/build/95016
    This will clone the space, if you click on Content {1}, you should already see a folder for German de {2} and English en {3} set up. If you click on Components in the sidebar, there are also already all the components, that are used in the Github repository, that we just cloned.

    Clone Space

    Connect Storyblok

    In the next step, we need to go to the Settings of our Storyblok space and retrieve our preview_token from the API Keys section {1}.

    Fill the Location (default environment) {2} as well as the Preview URLs {3} field with your localhost URL http://localhost:8000/.

    Preview Token and Live Preivew

    Copy the preview token under API-keys {1} into the gatsby.config.js file. Exchange the accessToken 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: process.env.NODE_ENV === 'production' ? 'published' : 'draft'
          }
        },
        ...
      ]
    }

    Now let's start the Gatsby.js server on port 8000 to see if everything is working.

    $ gatsby develop

    Now let's see that inside of Storyblok. Go to Content {1}, click on the en {2} folder and click on Home {3}.

    Content Navigation

    If your accessToken and default location {1} were set up correctly, you should see your development server inside of Storyblok {2}. If you're inside of Storyblok, you should also be able to click specific components and live-edit them {2}.

    Visual Editor

    In the next steps, we will explain step by step how this setup works, so you can adapt it to your needs.

    Dynamic Component Linking

    Since Storyblok works with a component approach, you will normally find the same components in Storyblok and your local project. To resolve the relationship between local React components and Storyblok components, we use the components/DynamicComponent.js file. Whenever you want to add a new component to be resolved you will need to add it to the Components object on Line 12. The key will be the technical component name from Storyblok, the value will be the import of the actual component.

    components/DynamicComponent.js
    import React from 'react'
    import Teaser from './Teaser'
    import Feature from './Feature'
    import Grid from './Grid'
    import BlogPost from './BlogPost'
    import Slide from './Slide'
    import Placeholder from './Placeholder'
    import ArticleTeaser from './ArticleTeaser'
    import FeaturedArticles from './FeaturedArticles'
    import PostsList from './PostsList'
    
    const Components = {
      'teaser': Teaser,
      'grid': Grid,
      'feature': Feature,
      'blogpost': BlogPost,
      'slide': Slide,
      'article-teaser': ArticleTeaser,
      'featured-articles': FeaturedArticles,
      'posts-list': PostsList
    }
    
    const DynamicComponent = ({blok}) => {
      if (typeof Components[blok.component] !== 'undefined') {
        const Component = Components[blok.component]
        return <Component blok={blok} key={blok._uid}/>
      }
     
      return  blok.component ? <Placeholder componentName={blok.component}/> : null
    }
    
    export default DynamicComponent
    

    This dynamic component allows us to automatically inject the right component based on our Storyblok content. You can see an example usage in the components/Page.js file, where we map over all the blok.body elements and inject the right component based on the component name.

    components/Page.js
    import React from "react"
    import DynamicComponent from "./DynamicComponent"
    import SbEditable from 'storyblok-react'
    
    const Page = ({ blok }) => {
      const content =
        blok.body &&
        blok.body.map(childBlok => <DynamicComponent blok={childBlok} key={childBlok._uid}/>)
      const hasTitle = blok.title && blok.title.length ? (<h1 className="text-5xl font-bold font-serif text-primary tracking-wide text-center py-8">{ blok.title }</h1>) : null
      return (
        <SbEditable content={blok} key={blok._uid}>
            { hasTitle }
            { content }
        </SbEditable>
      )
    }
    
    export default Page
    

    Requesting Content from Storyblok

    With all the components ready, let's take a look at our 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 graphQL query, you will need to restart your server. You can find more information on this in our Getting Started with Gatsby.js & Storyblok tutorial. The most important part is the use of the Storyblok hook in Line 8 from the src/utils/storyblok file.

    pages/index.js
    import React from "react"
    import Page from '../components/Page'
    import Layout from "../components/Layout"
    import { graphql } from 'gatsby'
    import useStoryblok from '../utils/storyblok'
    
    export default function PageIndex({ data, location }) {
        const story = useStoryblok(data.story, location)
    
        return (
          <Layout location={location}>
            <Page blok={story.content} />
          </Layout>
        )
    }
    
    export const query = graphql`
      {
        story: storyblokEntry(full_slug: { eq: "en/" }) {
          name
          content
          full_slug
          uuid
        }
      }
    `

    Using the Storyblok Bridge & Visual Editor Events

    To enable the connection to Storyblok, let's take a look at the src/utils/storyblok file. It takes a story and the location as a parameter. On Line 18, we're checking if the story.content is a string, and parsing it into a JSON object. This is necessary because of the gatsby-source-storyblok plugin returning the story.content object as a string. On Line 82 it loads the Storyblok Bridge, which is necessary to make the Visual Editor interactive. Once the Bridge script is loaded, it will add the events to listen for in Storyblok with the initEventListeners function.

    The storyblokInstance function can listen for different events: input when we change a field, published when we click publish, change when we click save or enterEditmode when the editor is opened.

    The input event on Line 36 uses the hook callback setStory to live-update the content inside of Storyblok.

    The enterEditmode event uses a Storyblok client to request the current draft version content, in case the project is set up with a published version of the content. This is useful to show the draft content inside of Storyblok, but the published content outside of Storyblok, when the project is deployed.

    src/utils/storyblok
    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 sbClient = 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({
            resolveRelations: "posts-list.posts"
          })
    
          storyblokInstance.on(['published', 'change'], (event) => {
            // reloade project on save an publish
            window.location.reload(true)
          })  
      
          storyblokInstance.on('input', (event) => {
            // live updates when editing
            setStory(event.story)
          }) 
    
          storyblokInstance.on('enterEditmode', (event) => {
            // loading the draft version on initial view of the page
            sbClient
              .get(`cdn/stories/${event.storyId}`, {
                version: 'draft',
                resolve_relations: "posts-list.posts"
              })
              .then(({ data }) => {
                console.log(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;
    }

    With this in place, the complete live preview is setup. You should be able to click on the components directly, edit them, and see the updates change live in the preview.

    Build a multi-language navigation

    To build dynamic navigation, you have several options. One approach is to use the links API to generate the navigation automatically from your content tree. Another option, the one our example space is using, is to create a global content entry/item which will contain the global configurations of our website.

    In both of the language folders, you will find a Settings story. If you open the story en/settings {1} in the form-only mode {2}, you will see the navigation entries {3}, which can set an internal link to other stories {4}. Because you have two duplicated stories for German and English, you can create a custom navigation for each language, including whichever pages you want to have in a specific language.

    Folder Creation

    The real path of the Settings entry is set to /, since it doesn't have its own page.

    The next step is to load the settings for all languages via GraphQL inside our components/Layout.js file. In Gatsby.js we can use the location object, that is passed to all pages to find out what the current language is. We then filter for the correct settings object for that language and pass it to our navigation component.

    components/Layout.js
    import React from "react"
    import Navigation from './Navigation'
    import Footer from './Footer'
    import { useStaticQuery, graphql } from "gatsby"
    
    export default function Layout({ children, location, lang }){
      const { settings } = useStaticQuery(graphql`
      query Settings {
        settings: allStoryblokEntry(filter: {field_component: {eq: "settings"}}) {
          edges {
            node {
              name
              full_slug
              content
            }
          }
        }
      } 
      `)
      let { pathname } = location
      let language = pathname.split("/")[1].replace('/', '')
      let activeLanguage = ['de', 'en'].includes(language) ? language : 'en'
      let correctSetting = settings.edges.filter(edge => edge.node.full_slug.indexOf(activeLanguage) > -1)
      let hasSetting = correctSetting && correctSetting.length ? correctSetting[0].node : {}
      let content = typeof hasSetting.content === 'string' ? JSON.parse(hasSetting.content) : hasSetting.content
      let parsedSetting = Object.assign({}, content, {content: content})
    
      return (
        <div className="bg-gray-300">
          <Navigation settings={parsedSetting} lang={activeLanguage} />
          <main>
          { children }
          </main>
          <Footer />
        </div>
      )
    }

    Automatic generation of multi-language pages

    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. You can attach your token to the following URL to see what the API returns for the content type Page:

    https://api.storyblok.com/v2/cdn/stories/?version=draft&content_type=page&token=YOUR_PREVIEW_TOKEN

    You will see the different page stories with their full slugs: de, de/blog, en and en/blog. What we need to do to generate those pages and the blog posts, is to add two template file: templates/page.js for general pages and templates/blog-entry.js for blog posts. Then we need to change the gatsby-node.js file to create all stories with the content type Page or Blogpost to use those templates.

    Let's take a look at the gatsby-node.js file:

    gatsby-node.js
    const path = require('path')
    
    function rewriteSlug(slug) {
      const defaultLanguage = 'en/'
      let newSlug = slug
      // replaces /de/home with /de
      newSlug = newSlug.replace('home', '')
      // replaces /en/blog/first-post with /blog/first-post
      newSlug = newSlug.replace(defaultLanguage, '')
      return newSlug
    }
    
    exports.createPages = ({ graphql, actions }) => {
      const { createPage } = actions
    
      return new Promise((resolve, reject) => {
        const blogPostTemplate = path.resolve('src/templates/blog-entry.js')
        const pageTemplate = path.resolve('src/templates/page.js')
    
        resolve(
          graphql(
            `{
              posts: allStoryblokEntry(filter: {field_component: {eq: "blogpost"}}) {
                edges {
                  node {
                    id
                    name
                    slug
                    field_component
                    full_slug
                    content
                  }
                }
              }
              pages: 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 allPosts = result.data.posts.edges
            const allPages = result.data.pages.edges
    
            allPosts.forEach((entry) => {
              const slug = rewriteSlug(entry.node.full_slug)
              const page = {
                path: `/${slug}`,
                component: blogPostTemplate,
                context: {
                  story: entry.node
                }
              }
              createPage(page)
            })
    
            allPages.forEach((entry) => {
              let slug = rewriteSlug(entry.node.full_slug)
              const page = {
                path: `/${slug}`,
                component: pageTemplate,
                context: {
                  story: entry.node
                }
              }
              createPage(page)
            })
          })
        )
      })
    }
    

    On Line 17 & 18 we're loading the correct template files. Then we're using the GraphQL API to get all our stories with the content type blogpost and page on Line 23 and 35. On Line 57 we're iterating over all the posts and getting the full_slug.

    We're making use of a helper function rewriteSlug (Line 3) to remove all the home parts for the root entries, so for our German home page we're generating the path de instead of de/home. We're also replacing the default language, so if English is our default we want the base path for blog posts to be blog/first-post instead of en/blog/first-post. We're using this helper function also in our components/Navigation.js and in the components/PostLists file, so we're generating the correct links from the full slugs that are returned by the API.

    Finally, our pages are generated with the correct slugs on Line 66 and 78. To the templates we pass a context object (Line 74), that can be used to load our Storyblok content per page. If you open templates/page.js, you can see that on Line 6, we're using pageContext instead of the data used in the pages/index.js file.

    templates/page.js
    import React from "react"
    import Page from '../components/Page'
    import Layout from "../components/Layout"
    import useStoryblok from '../utils/storyblok'
    
    export default function PageIndex({ pageContext, location }) {
        const story = useStoryblok(pageContext.story, location)
    
        return (
          <Layout location={location}>
            <Page blok={story.content} />
          </Layout>
        )
    }
    

    Creating a client-side fallback for new posts

    When we create a new post and haven't rebuilt our bundle on the server, we will see the Gatsby 404 page. In order to avoid that, we can load the data on the client-side in a pages/404.js file. Since our Storyblok hook is already capable of loading data on the client-side, we will make use of the hook to load pages when we're inside of Storyblok.

    pages/404.js
    import React from "react"
    import Page from '../components/Page'
    import Layout from "../components/Layout"
    import useStoryblok from '../utils/storyblok'
    
    export default function Page404({ location }) {
      const story = useStoryblok(null, location)
    
      let content = (<h1>Not found</h1>)
      if(story && story.content) content = (<Page blok={story.content} />)
    
      return (
        <Layout location={location}>
          { content }
        </Layout>
      )
    }

    The hook will use the enterEditmode to load the current draft story, if we're inside the editor and it should be displayed correctly, even if we haven't restarted our server or rebuilt the page.

    Now when we click Preview custom 404 pageon the Gatsby Developer Preview, we should see our blog post without reloading.

    Resolving Relations on Multi-Options fields

    If you open the en/blog/home story, you will see the posts-lists component. This component is set up with a multi-option field-type {1}, that allows referencing other story entries {2}. In this example, since we only want blog posts, we're limiting it to the content type blogpost {3}. Open the following link with your preview token to see what it returns:

    https://api.storyblok.com/v1/cdn/stories/en/blog/?version=draft&token=YOUR_PREVIEW_TOKEN

    If you take a look into story.content.body[0].posts, you will see, that it includes a list of uuids. To actually get the full story objects, we have to resolve the relations first. Take a look at the following link with your preview token attached:

    https://api.storyblok.com/v1/cdn/stories/en/blog/?version=draft&resolve_relations=posts-list.posts&token=YOUR_PREVIEW_TOKEN

    By using the resolve_relations option of the Storyblok API, we can get the full story objects of those related objects.

    Storyblok editing capabilities

    Inside of the utils/storyblok.js file you can find multiple places, where the relations are already resolved. Once directly on the Storyblok Bridge:

    const storyblokInstance = new StoryblokBridge({
            resolveRelations: "posts-list.posts"
    })

    And once on the client call:

    sbClient
      .get(`cdn/stories/${event.storyId}`, {
        version: 'draft',
        resolve_relations: "posts-list.posts"
      })

    Since the gatsby-source-storyblok module isn't able to resolve these deep relations yet, we're resolving the relations manually in the components/PostsList.js file on Line 11.

    components/PostsList.js
    import React from "react"
    import SbEditable from "storyblok-react"
    import { useStaticQuery, graphql } from "gatsby"
    
    import rewriteSlug from '../utils/rewriteSlug'
    
    const PostsList = ({ blok }) => {
      let filteredPosts = [];
      const isResolved = typeof blok.posts[0] !== 'string'
    
      const data = useStaticQuery(graphql`
        {
          posts: allStoryblokEntry(
            filter: {field_component: {eq: "blogpost"}}
          ) {
            edges {
              node {
                id
                uuid
                name
                slug
                full_slug
                content
                created_at
              }
            }
          }
        }
      `)
      if(!isResolved) {
        filteredPosts = data.posts.edges
        .filter(p => blok.posts.indexOf(p.node.uuid) > -1);
    
        filteredPosts = filteredPosts.map((p, i) => {
          const content = p.node.content
          const newContent = typeof content === 'string' ? JSON.parse(content) : content
          p.node.content = newContent
          return p.node
        })
      }
    
      const arrayOfPosts = isResolved ? blok.posts : filteredPosts
      return (
        <SbEditable content={blok} key={blok._uid}>
          <div className="container mx-auto">
          <ul className="flex flex-col justify-center items-center">
            {arrayOfPosts.map(post => {
              return (
                <li
                  key={post.name}
                  className="max-w-4xl px-10 my-4 py-6 rounded-lg shadow-md bg-white"
                >
                  <div className="flex justify-between items-center">
                    <span className="font-light text-gray-600">
                      {`
                        ${new Date(post.created_at).getDay()}.
                        ${new Date(post.created_at).getMonth()}.
                        ${new Date(post.created_at).getFullYear()}`}
                    </span>
                  </div>
                  <div className="mt-2">
                    <a
                      className="text-2xl text-gray-700 font-bold hover:text-gray-600"
                      href={`/${rewriteSlug(post.full_slug)}`}
                    >
                      {post.content.title}
                    </a>
                    <p className="mt-2 text-gray-600">{post.content.intro}</p>
                  </div>
                  <div className="flex justify-between items-center mt-4">
                    <a
                      className="text-blue-600 hover:underline"
                      href={`/${rewriteSlug(post.full_slug)}`}
                    >
                      Read more
                    </a>
                  </div>
                </li>
              )
            })}
          </ul>
          </div>
        </SbEditable>
      )
    }
    
    export default PostsList
    

    Deploying to Vercel

    You have multiple options for the deployment of your website/application to go live or to preview the environment. One of the easiest ways is to use Vercel and deploy using the command line or their outstanding GitHub Integration.

    First, create an account on Vercel and install their CLI application.

    npm i -g vercel

    Deploy your website by running the vercel in your console.

    vercel

    Take a look at the deployed Demo project.

    Multi-language setup complete!

    Congratulations! You now have a multi-language Gatsby website with automatic page and post generations as well as a live preview.

    Conclusion

    Gatsby.js and Storyblok make 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.

    Resource Link
    Github repository of this tutorial github.com/storyblok/gatsbyjs-multilanguage-website
    Demo Project gatsby-multilanguage-website.vercel.app
    Gatsby.js gatsbyjs.org
    React.js reactjs.org
    Storyblok App Storyblok