The Complete Guide to Build a Full Blown Multilanguage Website with Next.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 Next.js and Storyblok. With this step-by-step guide, you will get a dynamic Next.js website running on Vercel, using the Storyblok API to manage multilanguage content.

    If you are in a hurry you can download the whole source code of the project from GitHub https://github.com/storyblok/nextjs-multilanguage-website.

    You can also take a look at the deployed Demo project: nextjs-storyblok-multilanguage-website.vercel.app/

    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 below this paragraph. We will guide you through most of the topics of this tutorial, but if you are beginning with Next.js & Storyblok, you should consider checking out our Add a headless CMS to Next.js in 5 minutes guide to begin.

    Environment setup

    If you haven't done so already, install Node.js and NPM on your machine. After that, run the following shell commands. This will clone the example repository for the multi-language blog.

    $ git clone https://github.com/storyblok/nextjs-multilanguage-website
    $ cd nextjs-multilanguage-website
    $ npm install

    Cloning the Storyblok Template

    To get the correct space set up for the Github example repository, click the following link to duplicate our example multi-language space: https://app.storyblok.com/#!/build/95804. This will clone the space with the necessary content structure already set up.

    The first step is to set our Preview Url to our development server. In the Storyblok app, go to Settings {1} > Visual Editor {2}, and set the Location (default environment) {3} to https://localhost:3010/.

    For this tutorial, we will set up our dev server with an HTTPS proxy, to use a secure connection with the application. We'll use port 3010, so the URL to access our website will end up being https://localhost:3010/.

    app.storyblok.com
    Preview URL
    1
    2
    3

    Preview URL

    HINT:

    If you don’t know how to setup an HTTPS proxy on macOS, you can read this guide.

    You can also enter the URLs to enable and disable the preview mode: https://localhost:3010/api/preview?secret=MY_SECRET_TOKEN&slug= and https://localhost:3010/api/exit-preview?slug=. Read more about preview mode in our 5 minutes tutorial.

    Connect Storyblok

    In the next step, we need to retrieve the Preview token {3} from the Space Settings {1}, under Access Tokens {2}.

    app.storyblok.com
    Get Preview Access Token
    1
    2
    3

    Get Preview Access Token

    To activate the connection to Storyblok, open the pages/_app.js file and enter the preview token you just retrieved, where it says accessToken.

    pages/_app.js
    ...
    
    storyblokInit({
      accessToken: "YOUR_PREVIEW_TOKEN",
      use: [apiPlugin],
      components,
    });
    
    ...

    Let's open our Home Story now by clicking on Content {1} and then the Home Story {2}.

    app.storyblok.com
    Storyblok Content
    1
    2

    Storyblok Content

    If your development server is running and your token is set correctly, you should see the URL on the top {1} and the Storyblok Bridge activated {2}. You should be able to click on the components {3} and directly edit them.

    app.storyblok.com
    Storyblok Bridge working
    1
    2
    3

    Storyblok Bridge working

    Understanding the Storyblok Bridge & Visual Editor

    The Storyblok bridge is loaded and activated with the storyblokInit function in the pages/_app.js file, and via the Storyblok hook called useStoryblokState used in the dynamic route pages/[[...slug]].js. Read our Storyblok JS Bridge documentation to learn more about it.

    We should use (or not) the useStoryblokState hook to conditionally load the bridge, when we want it. In most cases, this would be used in combination with the preview mode, but can also be set to true to always load it.

    Since our visual editor is already set up with these settings, we can take a look at the content in Storyblok.

    Understanding the Content Structure

    Below you can see that once you cloned the example in Storyblok it will ship with sample content and components. We will use this content and the created components in our project. For your own project feel free to change, rename, or even delete those components and create your own. If you navigate to Block Library {1} in the main navigation you will see a list of components. These components, like the page component {2} for our pages and the post component {3} for our blog posts are ready to be used to in Storyblok and our project.

    app.storyblok.com
    Content Structure
    1
    2
    3

    Content Structure

    By default Storyblok ships with a component called Page. The Page component is a Content Type. This tells us that the Page component can be used to create new Stories. You would not be able to create new Stories from other components such as grid, feature, and teaser as they can only be used inside a field of the type blocks that lives inside a Content Type. That way you can create an almost infinite number of combinations with your components, with multiple levels of nesting.

    hint:

    Read more about components and the difference between Content Types and Bloks (nestable components) in an essential part of the developer guide.

    We also have some content already set up. If you navigate to Content {1}, you will see the Home {2} and About {3} stories with the content type Page. You will also see a folder for the blog posts called blog {4}, which will have our blog posts.

    app.storyblok.com
    Content is set up
    1
    2
    3
    4

    Content is set up

    hint:

    To better understand the content structure, we strongly recommend reading the Structures of Content chapter of the developer guide.

    Using Storyblok components in Next.js

    Now that we already have components defined in Storyblok, let's take a look at the implementation of the components defined in the Next.js project. Open the components/Page.js  file:

    components/Page.js
    import { storyblokEditable, StoryblokComponent } from "@storyblok/react";
    
    const Page = ({ blok }) => (
      <main {...storyblokEditable(blok)}>
        {blok.body
          ? blok.body.map((blok) => (
              <StoryblokComponent blok={blok} key={blok._uid} />
            ))
          : null}
      </main>
    );
    
    export default Page;

    By using storyblokEditable with any component, we can make them clickable in Storyblok Visual Editor. If you want to control which components are clickable, you can add or remove the storyblokEditable from the components.

    Explanation of the blok Prop

    You probably noticed the blok prop in all of the components in the project. The prop is used to pass data down into each of the components. Keep in mind that you need to pass the data down to the nested components to render them.

    Explanation of StoryblokComponent

    You might have noticed a StoryblokComponent in the code of some components (Page.js, Grid.js, etc.). We are using this feature to decide which component should be rendered on the screen depending on the component name it has in Storyblok. In the code of the pages/_app.js file, you can see that all the possible components are getting imported and listed in the components variable that is passed as a parameter to the storyblokInit function. This will get the correct component rendered according to the value of the blok.component property.

    pages/_app.js
    ...
    
    import BlogPost from "../components/BlogPost";
    import Feature from "../components/Feature";
    import FeaturedPosts from "../components/FeaturedPosts";
    import Grid from "../components/Grid";
    import Page from "../components/Page";
    import PostsList from "../components/PostsList";
    import Teaser from "../components/Teaser";
    import Text from "../components/Text";
    
    const components = {
      feature: Feature,
      "featured-posts": FeaturedPosts,
      grid: Grid,
      page: Page,
      post: BlogPost,
      "selected-posts": PostsList,
      teaser: Teaser,
      text: Text,
    };
    
    storyblokInit({
      accessToken: "YOUR_PREVIEW_TOKEN",
      use: [apiPlugin],
      components,
    });
    
    ...


    If you add a new component to your project, you will need to add it to that list.

    HINT:

    You should save sensitive data and your API tokens,even through the Storyblok Content Delivery API token is read only, in .env and not directly in the pages/_app.js file as we did.

    Using the Storyblok API client

    To request content from Storyblok, we set up the connection between Next.js and Storyblok to get the data for the components, using storyblokInit. In order to retrieve the data from the API, @storyblok/react provides us the getStoryblokApi function.

    getStoryblokApi gets the data from the Storyblok Content Delivery API, so we can use it in different files to request content.

    Generating Pages from Storyblok Stories

    We can automatically generate all the pages from our website with a single file: pages/[[..slug]].js

    This file is using Next.js dynamic routes feature, with static generation. We will explain the parts of it in more detail.

    getStaticPaths

    Let's start by taking a look at the function that generates the HTML markup for the pages. In Next.js we can use the getStaticPaths function to generate static routes.

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

    We use Storyblok's links endpoint to request all link entries from Storyblok. This endpoint retrieves not only story links, but also folders. We check if the link is a folder and, if that's the case, we don't create the route. We use Next.js catch all functionality to create all the routes. We create a route for each locale in Next.js. Read the Next.js tutorial Internationalized Routing to understand how that routing works. The basis for that is the locales defined in next.config.js:

    next.config.js
    module.exports = {
      i18n: {
        localeDetection: false,
        locales: ["en", "es"],
        defaultLocale: "en",
      },
    };

    These locales should match the languages defined in our Storyblok space under Settings {1}, Internationalization {2}.

    app.storyblok.com
    Languages
    1
    2

    Languages

    getStaticProps

    The next important function is the getStaticProps function. It's the function that generates static pages in Next.js. This is the place where we want to request data from Storyblok.

    pages/[[..slug]].js
    ...
    
    export async function getStaticProps({
      locale,
      locales,
      defaultLocale,
      params,
    }) {
      let slug = params.slug ? params.slug.join("/") : "home";
    
      let sbParams = {
        version: "draft",
        resolve_relations: ["featured-posts.posts", "selected-posts.posts"],
        language: locale,
      };
    
      let { data } = await getStoryblokApi().get(`cdn/stories/${slug}`, sbParams);
    
      return {
        props: {
          story: data ? data.story : false,
          key: data ? data.story.id : false,
          locale,
          locales,
          defaultLocale,
        },
        revalidate: 3600,
      };
    }
    
    ...

    We join the slug because in Catch-All routes the slug needs to be an array. If slug has no value, we're passing the slug home. Then, we set the parameters that we'll pass to the Storyblok API. We can set the version to request the published or draft content. We can resolve_relations to bring the content related to linked stories. For example, when we select related blog posts in our blog. After that, we request the correct language from Storyblok depending on which locale is active in Next.js.

    Finally, we're requesting the correct story entry from Storyblok in the correct language. In the return statement, we need to pass our props object.

    Render function

    The last missing part is our Page function, which makes use of the useStoryblokState hook. You can enable the StoryblokBridge and the Visual Editor on every page by default, or only inside of the preview mode. In most cases, you would only want to load the bridge if you're loading your page inside of the Visual Editor.

    We're passing our language locale to our Layout.js component and are automatically loading the right components, depending on the story content.

    pages/[[..slug]].js
    ...
    
    export default function Page({ story, locale, locales, defaultLocale }) {
      story = useStoryblokState(story, {
        resolveRelations: ["featured-posts.posts", "selected-posts.posts"],
        language: locale,
      });
    
      return (
        <Layout locale={locale} locales={locales} defaultLocale={defaultLocale}>
          <StoryblokComponent blok={story.content} />
        </Layout>
      );
    }
    
    ...

    Post Component in Next

    If we take a look at our BlogPost.js component file, we can see it's really similar to the other components.

    components/BlogPost.js
    import { storyblokEditable } from "@storyblok/react";
    import { render } from "storyblok-rich-text-react-renderer";
    
    const BlogPost = ({ blok }) => {
      return (
        <div {...storyblokEditable(blok)}>
          <div className="bg-white-half w-full">
            <div className="max-w-3xl mx-auto text-center pt-20 flex flex-col items-center">
              <h1 className="text-5xl font-bold font-serif text-primary tracking-wide">
                {blok.title}
              </h1>
              <p className="text-gray-500 text-lg max-w-lg">{blok.intro}</p>
              <img className="w-full bg-gray-300 my-16" src={blok.image} />
            </div>
          </div>
          <div className="max-w-3xl mx-auto text-center pt-20 flex flex-col items-center">
            <div className="leading-relaxed text-xl text-left text-gray-800 drop-cap">
              {render(blok.long_text)}
            </div>
          </div>
        </div>
      );
    };
    
    export default BlogPost;

    As you can see here, we used storyblok-rich-text-react-renderer to render our Richtext content. To make this component work correctly in new projects we need to install the package from npm. You don't have to do this for the cloned project, since it's already installed.

    Resolving Relations on Multi-Options fields

    If you open the /blog/home story, you will see the selected-posts 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 post. Open the following link with your preview token to see what it returns:

    https://api.storyblok.com/v1/cdn/stories/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. In order 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. This link is resolving the relations with the resolve_relations parameter:

    https://api.storyblok.com/v1/cdn/stories/blog/?version=draft&resolve_relations=selected-posts.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 posts.

    app.storyblok.com
    1
    2

    "selected-posts" component

    You can find multiple places where the relations are already resolved. Whenever we instantiate the Storyblok Bridge using the useStoryblokState hook:

      story = useStoryblokState(story, {
        resolveRelations: ["featured-posts.posts", "selected-posts.posts"],
        language: locale,
      });

    And also on the client call:

      let slug = params.slug ? params.slug.join("/") : "home";
    
      let sbParams = {
        version: "draft",
        resolve_relations: ["featured-posts.posts", "selected-posts.posts"],
        language: locale,
      };
    
      let { data } = await getStoryblokApi().get(`cdn/stories/${slug}`, sbParams);

    Adding Another Language

    In Storyblok, we can implement internationalization mainly with two different approaches, it will depend on the use case. Read more about Internationalization in this guide. We are going to use "Field Level Translation" for this example.

    If we want to add a new language to our space, all we need to do on the Storyblok side is go to the Settings {1} of our space and define a new language in the Internationalization {2} tab. Select a language {3} and click on the Add button {4}.

    app.storyblok.com
    Add a new language
    1
    2
    3
    4

    Add a new language

    If we open any story in the Visual Editor, we will see the language dropdown in the header {1} and we can switch to another language by clicking on it {2}.

    app.storyblok.com
    Selecting a language
    1
    2

    Selecting a language

    We need to define which fields we want to be able to translate. To see an example of how to do that, open the configuration for the field Headline {2} of the Teaser {1} component. Then click the Translatable check box {3}.

    app.storyblok.com
    Set field as translatable
    1
    2
    3

    Set field as translatable

    Change the language {1} to Spanish and you will see a Translate {2} toggle next to the translatable field. If you activate it, you are able to translate the value and you will see a real-time preview change. If the toggle stays false, the default language value will be used.

    app.storyblok.com
    Translate Field
    1
    2

    Translate Field

    To load the correct language version, we will need to pass the locale to our Storyblok Bridge, similar to the resolveRelations. In the pages/[[...slug]].js file, we pass the locale to the useStoryblokState.

    pages/[[...slug]].js
    ...
    
    export default function Page({ story, locale, locales, defaultLocale }) {
      story = useStoryblokState(story, {
        resolveRelations: ["featured-posts.posts", "selected-posts.posts"],
        language: locale,
      });
    
    ...

    Multi-language Navigation

    To show the correct Navigation depending on the language, we can pass our Next.js locale and locales to our Layout.

    pages/[[...slug]].js
    ...
    
        <Layout locale={locale} locales={locales} defaultLocale={defaultLocale}>
          <StoryblokComponent blok={story.content} />
        </Layout>
    
    ...

    The Layout file then passes the active locale, as well as all active locales, to the Navigation component.

    components/Layout.js
    import Head from "../components/Head";
    import Navigation from "../components/Navigation";
    import Footer from "../components/Footer";
    
    const Layout = ({ children, locale, locales, defaultLocale }) => (
      <div className="bg-gray-300">
        <Head />
        <Navigation
          locale={locale}
          locales={locales}
          defaultLocale={defaultLocale}
        />
        {children}
        <Footer />
      </div>
    );
    
    export default Layout;

    Finally, we can build our multi-language navigation in the components/Navigation.js file. We're resolving the current locale to get the correct language label. If you have a lot of navigation items, we recommend using a settings object in Storyblok to build your Navigation.

    components/Navigation.js
    import Link from "next/link";
    
    const Navigation = ({ locale, locales, defaultLocale }) => {
      const resolveHome = {
        en: "Home",
        es: "Página principal",
      };
      const resolveAbout = {
        en: "About",
        es: "Acerca",
      };
    
      const defaultRoot = locale === defaultLocale ? "/" : `/${locale}/`;
      return (
        <header className="w-full bg-white">
          <nav className="" role="navigation">
            <div className="container mx-auto p-4 flex flex-wrap items-center md:flex-no-wrap">
              <div className="mr-4 md:mr-8">
                <Link href="/">
                  <a>
                    <svg width="69" height="66" xmlns="http://www.w3.org/2000/svg">
                      <g fill="none" fillRule="evenodd">
                        <path fill="#FFF" d="M-149-98h1440v938H-149z" />
                        <path
                          d="M37.555 66c17.765 0 27.051-16.38 30.24-33.415C70.986 15.549 52.892 4.373 35.632.52 18.37-3.332 0 14.876 0 32.585 0 50.293 19.791 66 37.555 66z"
                          fill="#000"
                        />
                        <path
                          d="M46.366 42.146a5.55 5.55 0 01-1.948 2.043c-.86.557-1.811 1.068-2.898 1.3-1.087.279-2.265.511-3.487.511H22V20h18.207c.905 0 1.675.186 2.4.604a6.27 6.27 0 011.811 1.485 7.074 7.074 0 011.54 4.504c0 1.207-.317 2.368-.905 3.482a5.713 5.713 0 01-2.718 2.507c1.45.418 2.582 1.16 3.442 2.229.815 1.114 1.223 2.553 1.223 4.364 0 1.16-.226 2.136-.68 2.971h.046z"
                          fill="#FFF"
                        />
                      </g>
                    </svg>
                  </a>
                </Link>
              </div>
              <div className="text-black">
                <p className="text-lg">Storyblok</p>
                <p>NextJS Demo</p>
              </div>
              <div className="ml-auto md:hidden">
                <button
                  className="flex items-center px-3 py-2 border rounded"
                  type="button"
                >
                  <svg
                    className="h-3 w-3"
                    viewBox="0 0 20 20"
                    xmlns="http://www.w3.org/2000/svg"
                  >
                    <title>Menu</title>
                    <path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z" />
                  </svg>
                </button>
              </div>
              <div className="w-full md:w-auto md:flex-grow md:flex md:items-center">
                <ul className="flex flex-col mt-4 -mx-4 pt-4 border-t md:flex-row md:items-center md:mx-0 md:mt-0 md:pt-0 md:mr-4 md:ml-auto lg:mr-8 md:border-0">
                  <li>
                    <Link href={`${defaultRoot}`}>
                      <a className="block px-4 py-1 md:p-2 lg:px-8">
                        {resolveHome[locale]}
                      </a>
                    </Link>
                  </li>
                  <li>
                    <Link href={`${defaultRoot}blog`}>
                      <a className="block px-4 py-1 md:p-2 lg:px-8">Blog</a>
                    </Link>
                  </li>
                  <li>
                    <Link href={`${defaultRoot}about`}>
                      <a className="block px-4 py-1 md:p-2 lg:px-8">
                        {resolveAbout[locale]}
                      </a>
                    </Link>
                  </li>
                </ul>
                <ul className="flex flex-col mt-4 -mx-4 pt-4 border-t md:flex-row md:items-center md:mx-0 md:mt-0 md:pt-0 md:border-0">
                  {locales.map((loc) => {
                    return (
                      <li key={loc}>
                        <Link
                          href={`/${loc === defaultLocale ? "" : loc}`}
                          locale={false}
                        >
                          <a
                            className={`block px-4 py-1 md:p-2 rounded-lg lg:px-4 ${
                              locale === loc ? "bg-black text-white" : ""
                            }`}
                          >
                            {loc}
                          </a>
                        </Link>
                      </li>
                    );
                  })}
                </ul>
              </div>
            </div>
          </nav>
        </header>
      );
    };
    
    export default Navigation;

    We're iterating over all the locales that are set in Next.js and showing an entry to switch the locale {1}. We're also highlighting the active locale with a black background.

    app.storyblok.com
    Switch language
    1

    Switch language

    If you want to add another language, you will need to add an entry to the next-config.js file, as well as the language Settings in your Storyblok space.

    module.exports = {
      i18n: {
        localeDetection: false,
        locales: ["en", "es", "de"],
        defaultLocale: "en",
      },
    };

    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 the CLI application.

    Bash
    npm install -g vercel

    Deploy your website by running the vercel command in your console.

    Bash
    vercel

    Take a look at the deployed Demo project: nextjs-storyblok-multilanguage-website.vercel.app/

    Resource Link
    Github repository github.com/storyblok/nextjs-multilanguage-website
    The project deployed to Vercel nextjs-storyblok-multilanguage-website.vercel.app
    Storyblok React SDK storyblok/storyblok-react
    Vercel Vercel
    Next.js docs Next.js
    Storyblok App Storyblok
    Next.js Technology Hub Next.js Technology Hub
    Tailwind CSS with Next.js How to install Tailwind CSS with Next