Manage Multilingual Content in Storyblok and Next.js

Contents
    Try Storyblok

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

    Hint:

    Please note that this article has already been updated to match Storyblok V2. If you haven’t already started using it, you can find out how to make the switch here.

    In this tutorial, we will see how to add and manage multiple languages on our website. We will be using Next.js built-in support for internationalization on the front-end. We will also take a look at how to manage multiple languages in Storyblok. Along with all this, we will also add a simple language switcher to our website. 

    Hint:

    You can read more about Next.js Internationalized Routing here.

    There are four different approaches to implementing internationalization in Storyblok. You can use any one of them according to the requirements. In this tutorial, we will be using Field-Level translation. You can read more about all the approaches in detail here.

    Hint:

    If you’re in a hurry, you can find the live demo for this tutorial here.

    Requirements

    This is a part of the Ultimate Tutorial Guide for Next.js. You can find the previous part of the series here, which shows how to create custom components in Storyblok and Next.js. We recommend you to have completed that tutorial before starting this one.

    Hint:

    We will be using the code from the previous tutorial as a starting point. You can find it here.

    Adding a language in Storyblok

    Let's add the new language in Storyblok first. To add a new language go to Settings {1} and then to Internationalization {2}. Here you will find configuration related to field-level translation. Let's select Spanish from the drop-down {3} and hit the Add button {4}. You can select any language of your choice. We also need to save all the changes we did here from the top right corner {5}

    app.storyblok.com
    Adding a Language
    1
    2
    3
    4
    5

    Adding a Language

    If we go now to any of the stories, we will see a language dropdown on the action bar {1}.

    app.storyblok.com
    Article Story
    1

    We can switch the language from this dropdown. It won't work right now, because we haven't translated anything.

    As this is field-level translation, we need to make the fields translatable. Let's edit the title field of the article block and mark it as translatable {1}. Hit Save {2} after changing the field.

    app.storyblok.com
    Edit title field
    1
    2

    Edit title field

    If we switch the language now, we will get a 404 from Next.js. This is because with getStaticPaths inside our [...slug].js and index.js files, where we generate all the paths, we aren't generating the Spanish one. We will configure that in a while by adding the locale, and updating the Next.js config file.

    If you take a look at the translatable field now, you can observe a change in the UI {1}. You will see that the field is non-editable and has the default language's content in it. But here we also have the option to translate it {2}

    app.storyblok.com
    Spanish Article
    1
    2

    If we enable it, we can edit the field and add the content in Spanish. There is also an arrow button {1} which we see after toggling the translate option. If we expand that, it allows us to see the default language's content, add the default language’s content to the field itself with just a click, and go to Google Translate.

    app.storyblok.com
    Enabled Translation
    1

    Enabled Translation Field

    Let's hit publish with the translated content and configure our Next.js app. 

    Configuring i18n in Next.js

    Let's start with the next.config.js file. We need to add the i18n config to this file. If the file is not present in your directory, you can create a new file in the root directory. 

    Hint:

    If you want to know more about the next.config.js file, please take a look here.

    Copy the following code inside your config file:

    next.config.js
    ...
    module.exports = {
        i18n: {
          locales: ['en', 'es'],
          defaultLocale: 'en',
        },
      }
    ...

    With this code, we are adding two locales in the project - English and Spanish (line 3). We also state our default locale which is English (line 4). Once you add this code, you will need to restart the server to see the changes as the changes are in the config file. 

    Now we will be able to access the locale info in our getStaticProps and getStaticPaths methods. 

    Let's change the getStaticPaths inside [...slug].js to the following:

    [...slug].js
    ...
    export async function getStaticPaths({locales}) {
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get("cdn/links/");
      let paths = [];
      Object.keys(data.links).forEach((linkKey) => {
        if (data.links[linkKey].is_folder || data.links[linkKey].slug === "home") {
          return;
        }
        const slug = data.links[linkKey].slug;
        let splittedSlug = slug.split("/");
    
        for (const locale of locales) {
          paths.push({ params: { slug: splittedSlug }, locale });
        }
      });
      return {
        paths: paths,
        fallback: false,
      };
    ...

    You can see that now we are generating the pages for all the locales we have. In this case, two of them - English (default) and Spanish.

    Now if you refresh the page, you will see that we don't get a 404. Instead, we get the loaded page. But the loaded page is still using the default content. We need to update our getStaticProps as well. In your [...slug].js file, replace the getStaticProps with the following:

    [...slug].js
    ...
    export async function getStaticProps({ params, locale }) {
      let slug = params.slug ? params.slug.join("/") : "home";
      let sbParams = {
        version: "draft", // or 'published'
        language: locale
      };
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
      return {
        props: {
          locales, 
          story: data ? data.story : false,
          key: data ? data.story.id : false,
        },
        revalidate: 3600,
      };
    }
    ...

    If we now reload again, you will see that we get the new translated title. 

    Hint:

    You will need to do similar changes in the index.js file. It will require more configuration to see the changes inside the visual editor for the home page as it has a real path set to it and will not change the path when the language is switched from the dropdown. It will work fine in the browser. You can use the Advanced Paths app to configure the preview URL programmatically for the visual editor.

    app.storyblok.com
    Story with Translated content

    Story with Translated Content

    If we even try to switch the language from the dropdown, the content title also gets changed as we translated it. We can similarly translate more fields and content. The interesting thing is that this translation also works on more field types, for example, assets. We can even translate the image we have on the article page. We need to update the schema and mark it translatable similar to the way we did for the title. 

    Once done with the block schema update, we can now upload another image for the other language. This will allow us to use different images for different languages/regions.

    app.storyblok.com
    Story with Translated Image

    Story with Translated Image

    That's it, this is how easily you can translate your content and manage multiple languages with Storyblok and Next.js. You can similarly translate all the blogs and any other content you want.

    Let's also do a couple of more things like adding a language switcher in the navigation and translating the Blog homepage as it was getting the articles' data independently.

    Adding a Language Switcher

    As we will need to get the locale, and other related information inside the components like AllArticles.js as well as inside the Navigation.js, let's pass this information down through props. For this, we will need to first return this information from getStaticProps. After that, we will pass it down in the Layout component. 

    Replace the code inside [...slug].js with the following:

    [...slug].js
    import Head from "next/head";
    import Layout from "../components/Layout";
    import {
      useStoryblokState,
      getStoryblokApi,
      StoryblokComponent,
    } from "@storyblok/react";
    export default function Page({ story, locales, locale, defaultLocale }) {
      story = useStoryblokState(story, {
        language: locale
      });
      return (
        <div >
          <Head>
            <title>{story ? story.name : "My Site"}</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <Layout locales={locales} locale={locale} defaultLocale={defaultLocale}>
            <StoryblokComponent blok={story.content} locale={locale}  />
          </Layout>
        </div>
      );
    }
    export async function getStaticProps({ params, locales, locale, defaultLocale }) {
      let slug = params.slug ? params.slug.join("/") : "home";
      let sbParams = {
        version: "draft", // or 'published'
        language: locale
      };
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
      return {
        props: {
          locales, 
          locale, 
          defaultLocale,
          story: data ? data.story : false,
          key: data ? data.story.id : false,
        },
        revalidate: 3600,
      };
    }
    export async function getStaticPaths({locales}) {
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get("cdn/links/");
      let paths = [];
      Object.keys(data.links).forEach((linkKey) => {
        if (data.links[linkKey].is_folder || data.links[linkKey].slug === "home") {
          return;
        }
        const slug = data.links[linkKey].slug;
        let splittedSlug = slug.split("/");
    
        for (const locale of locales) {
          paths.push({ params: { slug: splittedSlug }, locale });
        }
      });
      return {
        paths: paths,
        fallback: false,
      };
    }

    Hint:

    You will need to make similar changes inside the index.js file.

    Now inside the Layout.js file, we will pass this information to Navigation.js with the props. The Layout.js file should now look like this:

    Layout.js
    import Navigation from "./Navigation";
    import Footer from "./Footer";
    const Layout = ({ children, locale, locales, defaultLocale }) => (
      <div>
        <Navigation locales={locales} locale={locale} defaultLocale={defaultLocale}
        />
        {children}
        <Footer />
      </div>
    );
    export default Layout;

    Now, once we get this locale information inside the Navigation.js, we will use it to make the simple language switcher. Replace the code inside the Navigation.js file with the following:

    Navigation.js
    import { useState } from "react";
    import Link from "next/link";
    import { useRouter } from 'next/router'
    const Navigation = ({locales, locale, defaultLocale}) => {
      const router = useRouter()
      const [openMenu, setOpenMenu] = useState(false);
      const changeLocale = (loc) => {
        router.push(router.asPath, router.asPath ,{locale: loc})
      }
      return (
        <div className="relative bg-white border-b-2 border-gray-100 z-20">
          <div className="max-w-7xl mx-auto px-4 sm:px-6">
            <div className="flex justify-between items-center  py-6 md:justify-start md:space-x-10">
              <div className="flex justify-start lg:w-0 lg:flex-1">
                <Link href="/">
                  <a>
                    <span className="sr-only">Storyblok</span>
                    <img
                      className="h-20 w-auto sm:h-10 hidden sm:block"
                      src='https://a.storyblok.com/f/88751/251x53/0d3909fe96/storyblok-primary.png'
                      alt="Storyblok"
                    />
                    <img
                      className="h-20 w-auto sm:h-10 sm:hidden"
                      src='https://a.storyblok.com/f/88751/92x106/835caf912a/storyblok-logo.png'
                      alt="Storyblok"
                    />
                  </a>
                </Link>
              </div>
              <div className="-mr-2 -my-2 md:hidden">
                <button
                  type="button"
                  onClick={() => setOpenMenu(true)}
                  className="bg-white rounded-md p-2 inline-flex items-center justify-center text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
                  aria-expanded="false"
                >
                  <span className="sr-only">Open menu</span>
                  {/* <!-- Heroicon name: outline/menu --> */}
                  <svg
                    className="h-6 w-6"
                    xmlns="http://www.w3.org/2000/svg"
                    fill="none"
                    viewBox="0 0 24 24"
                    stroke="currentColor"
                    aria-hidden="true"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth="2"
                      d="M4 6h16M4 12h16M4 18h16"
                    />
                  </svg>
                </button>
              </div>
              <div className="hidden md:flex items-center justify-end md:flex-1 lg:w-0 space-x-10">
                <Link href="/about">
                  <a className="text-base font-medium text-gray-500 hover:text-gray-900">
                    
                    About
                  </a>
                </Link>
                <Link href="/blog">
                  <a className="text-base font-medium text-gray-500 hover:text-gray-900">
                    
                    Blog
                  </a>
                </Link>
                <Link href="/services">
                  <a className="text-base font-medium text-gray-500 hover:text-gray-900">
                    
                    Services
                  </a>
                </Link>
                {locales.map(loc => (     
                    <span key={loc} onClick={ () => changeLocale(loc)}
                        className={`block px-4 py-1 md:p-2 rounded-lg lg:px-4 cursor-pointer ${
                          locale === loc ? "bg-black text-white" : ""
                        }`}>
                        {loc}
                  </span>
                  ))}
              </div>
            </div>
          </div>
          {/* <!--
            Mobile menu, show/hide based on mobile menu state.
          --> */}
          {openMenu && (
            <div className="absolute top-0 inset-x-0 p-2 transition transform origin-top-right md:hidden">
              <div className="rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 bg-white divide-y-2 divide-gray-50">
                <div className="pt-5 pb-6 px-5">
                  <div className="flex items-center justify-between">
                    <div>
                      <img
                        className="h-8 w-auto"
                        src="https://a.storyblok.com/f/88751/92x106/835caf912a/storyblok-logo.png"
                        alt="Storyblok"
                      />
                    </div>
                    <div className="-mr-2">
                      <button
                        type="button"
                        onClick={() => setOpenMenu(false)}
                        className="bg-white rounded-md p-2 inline-flex items-center justify-center text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
                      >
                        <span className="sr-only">Close menu</span>
                        {/* <!-- Heroicon name: outline/x --> */}
                        <svg
                          className="h-6 w-6"
                          xmlns="http://www.w3.org/2000/svg"
                          fill="none"
                          viewBox="0 0 24 24"
                          stroke="currentColor"
                          aria-hidden="true"
                        >
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            strokeWidth="2"
                            d="M6 18L18 6M6 6l12 12"
                          />
                        </svg>
                      </button>
                    </div>
                  </div>
                  <div className="mt-6">
                    <nav className="grid gap-y-8">
                      <Link href="/about">
                        <a className="-m-3 p-3 flex items-center rounded-md hover:bg-gray-50">
                          {/* <!-- Heroicon name: outline/chart-bar --> */}
                          <span className="ml-3 text-base font-medium text-gray-900">
                            
                            About
                          </span>
                        </a>
                      </Link>
                      <Link href="/blog">
                        <a className="-m-3 p-3 flex items-center rounded-md hover:bg-gray-50">
                          {/* <!-- Heroicon name: outline/cursor-click --> */}
                          <span className="ml-3 text-base font-medium text-gray-900">
                            
                            Blog
                          </span>
                        </a>
                      </Link>
                      <Link href="/services">
                        <a className="-m-3 p-3 flex items-center rounded-md hover:bg-gray-50">
                          <span className="ml-3 text-base font-medium text-gray-900">
                            
                            Services
                          </span>
                        </a>
                      </Link>
                      {locales.map(loc => (     
                    <span key={loc} onClick={ () => changeLocale(loc)}
                        className={`block px-4 py-1 md:p-2 rounded-lg lg:px-4 cursor-pointer ${
                          locale === loc ? "bg-black text-white" : ""
                        }`}>
                        {loc}
                  </span>
                  ))}
                    </nav>
                  </div>
                </div>
              </div>
            </div>
          )}
        </div>
      );
    };
    export default Navigation;

    You will see that in this file, we loop through all the locales and use a method to change the locale whenever a locale is clicked.

    After updating the navigation, the website should now have a working language switcher {1}

    localhost:3000
    With Language Switcher
    1

    With Language Switcher

    Right now, if we go to the Blog home by clicking on Blog in the navigation, we will see even when the blogs are translated, the blog teasers are in English. This is because we are not fetching the locale-specific data inside the AllArticles.js component.

    Translating the AllArticles Components

    Let's start with passing the props through Page.js. The new Page.js file should look like this:

    Page.js
    import { storyblokEditable, StoryblokComponent } from "@storyblok/react";
    const Page = ({ blok, locale }) => (
      <main className="text-center mt-4" {...storyblokEditable(blok)}>
        {blok.body.map((nestedBlok) => (
          <StoryblokComponent className='' blok={nestedBlok} key={nestedBlok._uid} locale={locale} />
        ))}
      </main>
    );
    export default Page;
    

    Now inside the AllArticles.js, let's fetch the locale-specific data by adding the language parameter (line 9) to the API URL. 

    The updated code should look like this:

    AllArticles.js
    import ArticleTeaser from "./ArticleTeaser";
    import { getStoryblokApi, storyblokEditable } from "@storyblok/react";
    import { useState, useEffect } from "react";
    const AllArticles = ({ blok, locale }) => {
      const [articles, setArticles] = useState([]);
      useEffect(() => {
        const getArticles = async () => {
          const storyblokApi = getStoryblokApi();
          const { data } = await storyblokApi.get(`cdn/stories?starts_with=blog&language=${locale}`);
          const filteredArticles = data.stories.filter((article) => article.name != "Home");
          
          setArticles((prev) => filteredArticles.map((article) => {
            article.content.slug = article.slug;
            return article;
          }));
        };
        getArticles();
    }, [locale]);
      return (
        <>
          <p className="text-3xl">{blok.title}</p>
          <div
            className="grid w-full grid-cols-1 gap-6 mx-auto lg:grid-cols-3   lg:px-24 md:px-16"
            {...storyblokEditable(blok)}
          >
            { articles[0] && articles.map((article) => (
              <ArticleTeaser article={article.content} key={article.uuid} />
            ))}
          </div>
        </>
      );
    };
    export default AllArticles;

    And now you will see that all of the articles on the Blog homepage are translated. 

    app.storyblok.com
    Blog Home

    Blog Home

    Congratulations, now you can build a full-blown multilingual Next.js website with Storyblok!

    Wrapping Up

    In this tutorial, you saw how to add and manage multiple languages in our Next.js and Storyblok website using Storyblok's field-level translation approach with the built-in support for internationalization in Next.js. We also added a basic language switcher on the navigation bar of the website.

    Next Part:

    In the next part of this series, we will see how to Create a Preview Environment for Your Next.js Website. It will be published soon, along with more parts. In the meantime, you can already start managing content in different languages with Storyblok and Next.js.

    Developer Newsletter

    Want to stay on top of the latest news and updates in Storyblok?
    Subscribe to Code & Bloks - our headless newsletter.

    An error occurred. Please get in touch with marketing@storyblok.com

    Please select at least one option.

    Please enter a valid email address.

    This email address is already registered.

    Please Check Your Email!

    Almost there! To confirm your subscription, please click on the link in the email we’ve just sent you. If you didn’t receive the email check your ’junk folder’ or