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

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

  • Home
  • Tutorials
  • The Complete Guide to Build a Full Blown Multilanguage Website with Next.js

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/

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

Section titled Environment setup 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
    

Section titled Cloning the Storyblok Template 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/.

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.


Section titled Connect Storyblok Connect Storyblok

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

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}.

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.

Storyblok Bridge working
1
2
3

Storyblok Bridge working

Section titled Understanding the Storyblok Bridge & Visual Editor 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.

Section titled Understanding the Content Structure 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.

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.

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.

Section titled Using Storyblok components in Next.js 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.

Section titled Explanation of the blok Prop 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.

Section titled Explanation of StoryblokComponent 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.

Section titled Using the Storyblok API client 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.

Section titled Generating Pages from Storyblok Stories 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.

Section titled getStaticPaths 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}.

Languages
1
2

Languages

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

Section titled Render function 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>
  );
}

...
    

Section titled Post Component in Next 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.


Section titled Resolving Relations on Multi-Options fields 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.

"selected-posts" component
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);
    

Section titled Adding Another Language 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}.

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}.

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}.

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.

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,
  });

...
    

Section titled Multi-language Navigation 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.

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",
  },
};
    

Section titled Deploying to Vercel 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/

ResourceLink
Github repositorygithub.com/storyblok/nextjs-multilanguage-website
The project deployed to Vercelnextjs-storyblok-multilanguage-website.vercel.app
Storyblok React SDKstoryblok/storyblok-react
VercelVercel
Next.js docsNext.js
Storyblok AppStoryblok
Next.js Technology HubNext.js Technology Hub
Tailwind CSS with Next.jsHow to install Tailwind CSS with Next

Authors

Lisi Linhart

Lisi Linhart

Lisi is a front-end engineer with a passion for web animation and UX. She is working for Storyblok as a Developer Experience Engineer from Salzburg, Austria.

Facundo Giuliani

Facundo Giuliani

Facundo is a Developer Relations Engineer at Storyblok. From Buenos Aires, Argentina, he has more than 15 years of experience in software development. Full Stack Developer. Auth0 Ambassador. Prisma Ambassador. Cloudinary Media Developer Expert. He is also an open-source contributor.