Almost EVERYONE who tried headless systems said they saw benefits. Download the state of CMS now!

Storyblok now on AWS Marketplace: Read more

O’Reilly Report: Decoupled Applications and Composable Web Architectures - Download Now

Empower your teams & get a 582% ROI: See Storyblok's CMS in action

Skip to main content

Manage Multilingual Content in Storyblok and Next.js

Try Storyblok

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

  • Home
  • Tutorials
  • Manage Multilingual Content in Storyblok and Next.js

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. Alternatively, you can explore or fork the code from the Next Ultimate Tutorial GitHub Repository.

Section titled Requirements 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.


Section titled Adding a language in Storyblok Adding a language in Storyblok

First, let's add a new language to our Storyblok space. Go to Settings {1} and click on Internationalization {2}. Here, you will find the configuration for field-level translation.

Hint:

Although you can select any language you want, for the purpose of this tutorial we will use Spanish as the second language.

Let's select the Spanish language from the drop-down {3} and hit the Add button {4}. Once the Spanish language is added, save the changes by clicking on the Save button {5}.

Adding a language in the Storyblok space
1
2
3
4
5

Adding a language in the Storyblok space

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

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.

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}

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.

Enabled Translation
1

Enabled Translation Field

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

Section titled Configuring i18n in Next.js 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/" ,{
    version: 'draft'
  });
  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.

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.

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.

Section titled Adding a Language Switcher 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/" ,{
    version: 'draft'
  });
  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}

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.

Section titled Translating the AllArticles Components 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. 

Blog Home

Blog Home

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

Section titled Wrapping Up 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.

Author

Chakit Arora

Chakit Arora

Chakit is a Full Stack Developer based in India, he is passionate about the web and likes to be involved in the community. He is a Twitter space host, who likes to talk and write about technology. He is always excited to try out new technologies and frameworks. He works as a Developer Relations Engineer at Storyblok.