The Complete Guide to Building a Full-Blown Multilanguage Website with Nuxt.js

Contents
warn:

The project in this article was developed using the following versions of these technologies:

  • Nuxt.js v2.14.0
  • Nodejs v12.3.1
  • Npm v6.9.0

Keep in mind that these versions may be slightly behind the latest ones.

This guide is for beginners and professionals who want to build a full-blown multilanguage website using Nuxt.js. With this step by step guide you will get a dynamic Nuxt.js website running on a Vercel (previously ZEIT now) server, using an API for the multilanguage content.

If you are in a hurry you can download the whole source code of the project at Github https://github.com/storyblok/nuxtjs-multilanguage-website or jump into one of the following chapters.

  1. Introduction

  2. Setting up the environment

  3. Building a skeleton

  4. Creating the content structure

  5. Connecting Storyblok with Nuxt.js

  6. Setting up a real-time Visual Editor

  7. Extending schema of bloks in Storyblok

  8. Creating article content type

  9. Showing Featured Articles on the homepage

  10. Creating articles overview page

  11. Adding another language

  12. Deploying to Vercel

Setting Up the Environment

Requirements

Initialization of the Nuxt.js App/Website

We will start by initializing the project with the Nuxt.js create app template. Navigate through the guide and choose the following options:

  • JavaScript as the programming language

  • Package manager of your choice (I used Yarn)

  • TailwindCSS as UI Framework

  • No Nuxt.js modules

  • Linting tools of your choice (I used none in the sample project)

  • Universal (SSR / SSG) rendering mode

  • Static/JAMStack hosting as a deployment target

  • Development tools of your choice (I used VS Code)

yarn create nuxt-app mywebsite // npx create-nuxt-app mywebsite
cd mywebsite
yarn dev // npm run dev

Nuxt.js starts its server on port 3000 by default so after running yarn dev (npm run dev) open your browser at http://localhost:3000. You should see a welcome screen of Nuxt.js.

Welcome screen of Nuxt.js

Building a Skeleton

Since we are using TailwindCSS we don't need to define any special folder structure for our style. We can jump directly into the structuring and styling of our pages/components.

Defining the Default Layout

First, we will design the default layout of our website. Copy & paste the following code into the file layouts/default.vue.

layouts/default.vue

<template>
  <div>
    <Header />
    <Nuxt />
    <Footer />
  </div>
</template>

<script>
import Header from '~/components/Header.vue'
import Footer from '~/components/Footer.vue'

export default {
  components: {
    Header,
    Footer
  }
}
</script>

Our default layout wants to use components named Header and Footer. We didn't create these components, so we will get the Nuxt.js error. Let's create them as the next step.

Creating a Header & Footer

Create two new files named ./components/Header.vue and ./components/Footer.vue with the following source code. At this moment we are only defining the layout of our page with the mock data.

./components/Header.vue

<template>
  <header class="max-w-5xl mx-auto py-8 flex">
    <nav>
      <ul>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/">
            Home
          </nuxt-link>
        </li>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/en/blog">
            Blog
          </nuxt-link>
        </li>
      </ul>
    </nav>
    <div class="flex-1">
      <a href="/" class="block w-56 mx-auto">
        <img src="http://a.storyblok.com/f/42016/1096x313/0353bf6654/logo2.png">
      </a>
    </div>
    <nav>
      <ul>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/en/blog">
            English
          </nuxt-link>
        </li>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/de/blog">
            German
          </nuxt-link>
        </li>
      </ul>
    </nav>
  </header>
</template>

./components/Footer.vue

<template>
  <footer class="py-10 bg-teal-100">
    <div class="max-w-5xl mx-auto text-center">
      <nuxt-link
        class="text-gray-500 hover:text-gray-800 hover:underline"
        to="/en/sitemap">
        Sitemap
      </nuxt-link>
    </div>
  </footer>
</template>

The result you will see on localhost 3000 should be similar to the following image.

Nuxt.js welcome screen with added header & footer components.
COMMIT:

You can check the current progress in this GitHub commit.

Creating the Content Structure

Up to this point, we only used hard-coded mockup data in our Nuxt.js project. Before we connect Storyblok with Nuxt.js and render our first data onto the screen, we must define the structure of our data in Storyblok. A good thing about this process is that we don't need to set up the connection and you can even prepare the data before you choose the front-end framework.

hint:

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

First signup or login at app.storyblok.com and create a new space. You can name it anything you like. The first screen you see should be similar to the following image:

First screen of Storyblok UI after entering the space first time.

As you can see here, Storyblok creates your default content. We will use this content and these components in our project. Feel free to delete this content and defined components in real-world projects. If you navigate in the Components section in the left navigation, you will see the following screen with already defined components (page. teaser, feature, grid). These components are ready to be used to create new stories in Storyblok.

Components screen showing the default components.

As you can see in the image, the type of the Page component is Content Type. This means that the Page represents the highest level of components and all the other components will live inside of it. In this case, our Page will be the page of our website. Feature, Grid and Teaser are Nestable, which means they can be used inside any component as a reusable blok. This 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.

Now we have components defined in Storyblok, but we don't have the implementation of these components defined in the Nuxt.js project. Let's create them using the following source code.

Page

./components/Page.vue

<template>
  <div
    v-editable="blok"
    class="px-6">
    <component
      v-for="blok in blok.body"
      :key="blok._uid"
      :blok="blok"
      :is="blok.component" />
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Grid

./components/Grid.vue

<template>
  <ul
    v-editable="blok"
    class="flex py-8 mb-6">
    <li
      :key="blok._uid"
      v-for="blok in blok.columns"
      class="flex-auto px-6">
      <component :blok="blok" :is="blok.component" />
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Feature

./components/Feature.vue

<template>
  <div
    v-editable="blok"
    class="py-2">
    <h1 class="text-lg">{{ blok.name }}</h1>
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Teaser

./components/Teaser.vue

<template>
  <div
    v-editable="blok"
    class="py-8 mb-6 text-5xl font-bold text-center">
    {{ blok.headline }}
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Explanation of Blok Prop

You probably noticed the blok prop in all of the components created by us. We are using this prop to pass the data into each of the components. There are various solutions for this challenge and you don't have to call this prop blok as we did. Keep in mind that you need to pass the data down to the nested components to render them.

In the case of the Page component, we are using the content from the blok prop to decide which component should be dynamically rendered on the screen. It is done by the dynamic <component> element (check the docs of it) and it's special is attribute, which defines what component should be rendered. Which component should be rendered is defined in the blok.component and can be used as this :is="blok.component".

COMMIT:

You can check the current progress in this Github commit.

Connecting Storyblok with Nuxt.js

So far we have created the skeleton of our website and prepared the default components in Nuxt.js. Before we start with the design of the pages, we need to set up the connection between Nuxt.js and Storyblok to get the data for the components. To do that, we can use the storyblok-nuxt module created by the Storyblok team. Install it as shown in the following example:

bash

yarn add storyblok-nuxt // npm install storyblok-nuxt --save

Then open the nuxt.config.js file and set up the storyblok-nuxt module in the modules section. Replace the STORYBLOK_SPACE_TOKEN with a preview access token of your space. You can find all tokens of your space in Settings under API-Key tab.

./nuxt.config.js

export default {
.
.
.
  /*
  ** Nuxt.js modules
  */
  modules: [
    [
      'storyblok-nuxt',
      {
        accessToken: 'STORYBLOK_SPACE_TOKEN',
        cacheProvider: 'memory'
      }
    ],
  ],
.
.
.
}

Check the following image if you struggle to find the preview access token.

Sample of where to find preview access token of your space.

If you run yarn dev in your terminal after these steps, you should see the same screen as before.

warn:

You should save your access tokens and other sensitive data in .env and not directly in the nuxt.config.js as we did.

Updating the Index Page & Requesting First Data

Replace the source code of the pages/index.vue with the following code.

./pages/index.vue

<template>
  <section>
    <component
      v-if="story.content.component"
      :key="story.content._uid"
      :blok="story.content"
      :is="story.content.component" />
  </section>
</template>

<script>
export default {
  data () {
    return {
      story: { content: {} }
    }
  },
  mounted () {
    // Use the input event for instant update of content
    this.$storybridge.on('input', (event) => {
      if (event.story.id === this.story.id) {
        this.story.content = event.story.content
      }
    })
    // Use the bridge to listen the events
    this.$storybridge.on(['published', 'change'], (event) => {
      // window.location.reload()
      this.$nuxt.$router.go({
        path: this.$nuxt.$router.currentRoute,
        force: true,
      })
    })
  },
  asyncData (context) {
    // // This what would we do in real project
    // const version = context.query._storyblok || context.isDev ? 'draft' : 'published'
    // const fullSlug = (context.route.path == '/' || context.route.path == '') ? 'home' : context.route.path

    // Load the JSON from the API - loadig the home content (index page)
    return context.app.$storyapi.get('cdn/stories/home', {
      version: 'draft'
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

You should get an error now because we didn't register our component in Nuxt.js. So let's do it and quickly create a simple Nuxt.js plugin for it. Create a file plugins/components.js with the following code.

./plugins/components.js

import Vue from 'vue'
import Page from '~/components/Page.vue'
import Teaser from '~/components/Teaser.vue'
import Grid from '~/components/Grid.vue'
import Feature from '~/components/Feature.vue'

Vue.component('page', Page)
Vue.component('teaser', Teaser)
Vue.component('grid', Grid)
Vue.component('feature', Feature)

And register the plugin in nuxt.config.js.

./nuxt.config.js

export default {
.
.
.
  /*
  ** Plugins to load before mounting the App
  ** https://nuxtjs.org/guide/plugins
  */
  plugins: [
    '~/plugins/components'
  ],
.
.
.
}

Your localhost:3000 should look similar to the following image.

Look of the homepage after applying the default changes.
COMMIT:

You can check the current progress in this Github commit.

Setting Up a Real-Time Visual Editor

Our website is running on the localhost:3000 and there we can preview the content of the Home story. The better way to preview your content is by using the real-time preview in Visual Editor, where you and your editors can see the changes instantly in real-time.

If you open the Home story it should look like this:

Preview of content section in the Storyblok UI.

You will see Visual Editor without the Preview setup (like on the next image). You may edit your content, or even save it and publish it. You will still need to go to another browser tab and open the localhost:3000 to see the result, so let's change it.

Visual Editor without setup of the Preview.

Go into the Settings and in the General tab to change the value of the Location (default environment) to http://localhost:3000/. You can also create a special preview URL for different environments like the preview from Vercel, Netlify, or other hosting platforms. This can be done by clicking on "Add preview url" button on the screen.

Setup of the preview URL.

If you open your Home story using the Visual Editor right now, you will see a Nuxt.js error message. This is good and it means that we connected Nuxt.js with Storyblok. Nuxt.js tells us that we are trying to reach the non-existent URL of http://localhost:3000/home as the slug of the Home story is home.

Let's fix this by overriding the Real Path property of the Home story to / as this story represents the homepage (index.vue) and shouldn't have any slug. We cannot remove the slug in Storyblok and our API needs to somehow reach this story.

Setup of Real Path in Home story.

Don't forget to save the changes. You should see the update of the preview after the save and at this moment your real-time preview is up and running. Try to edit the content of the story and see the instant change on the left-hand side.

Visual Editor with running preview.

Understanding the Structure of the Components

Since we already have a working Visual Editor with a preview function, we can click on different highlighted areas and the content editor will open in the right-hand side of the Visual Editor. You can also open and collapse these components (bloks) in the right-hand side editor without using the preview.

You can see the opened Grid component and its content in the next image. The Grid in this case consists only of an array of other bloks (nested components) and in this particular case is made up of three Feature components. Feel free to add more or remove one of them.

Opened sample of the Grid component in the Visual Editor.

If you inspect the source code using the Vue.js devtools you will see the same structure of components that we created in Storyblok.

Homepage shown in the Google inspecter. Pointing out the structure of components is same as in Storyblok.

Extending the Schema of Bloks in Storyblok

We have loaded the demo content of Storyblok (added by default at the creation of the new space) and set up the real-time visual editor. In this section we will extend existing bloks (nested components) with new fields and adjust their render templates in Nuxt.js.

Update of the Teaser

First, we will add an image asset to the Teaser component. Go to the Components section of Storyblok UI and click on the Teaser component. Create a new field named image in the opened overlay.

Creation of new field named Image in Teaser component.

Click on the created image field and define its type to Asset and as field-type allow only Images. Save the changes.

Configuration of the field named image.

Open Home story in the Visual Editor and click on the Teaser. You should see the option to add an image to the Teaser. Add one now.

Image field in the Teaser component in Visual Editor.

We have to update our nuxt component ./components/teaser.vue with the following code to render the image.

./components/teaser.vue

<template>
  <div
    v-editable="blok"
    class="pb-8 mb-6 font-bold text-center">
    <img
      class="h-48 w-full mb-4 object-cover"
      :src="blok.image.filename" />
    <h3 class="text-5xl">{{ blok.headline }}</h3>
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Right now you should see a result similar to this:

Result on homepage after teaser update.

Extending the Feature Component

Every feature component currently only has one title. Let's extend the Feature blok (nested component), including a description text and an icon.

Open up the schema of the component directly from the Visual Editor. Click on one of the Features components and in the right-hand corner you should see the Define Schema button. Click on it and the Schema overlay will open.

Sample how to open schema from Visual Editor directly.

Now we need to define two new fields in the Feature component. Add a field named description of the type textarea, and a field named icon of the type image.

Updated feature blok with description and icon field.

Open the Feature component in Nuxt.js components/Feature.vue and extend it with the new fields as well.

./components/Feature.vue

<template>
  <div
    v-editable="blok"
    class="py-2 text-center">
    <img
      class="mx-auto"
      :src="blok.icon">
    <h1 class="text-lg">{{ blok.name }}</h1>
    <p class="text-gray-600">
      {{ blok.description }}
    </p>
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

Finally, add new content to your features and you should see results similar to the following image:

Result after update of features.
COMMIT:

You can check the current progress in this Github commit.

Creating the Article Content Type

Next we are going to create our first Content Type called Article. It will hold the very simple content of our blogpost. Go to the Components section of the Storyblok UI and click on New in the top right-hand corner. Add the name article to the new content type and set Act as content type (eg. blog-post) to true.

Creation of Article content type.

Define in the schema of Article the fields name (Text), intro (Textarea), and body (Richtext).

Configuration of article content type.

Create a folder named articles in the root of Content section in Storyblok UI.

Creation of articles folder.

Create a Article.vue component with the following source code in Nuxt.js project.

./components/Article.vue

<template>
  <div
    v-editable="blok"
    class="prose my-24 mx-auto">
    <h1>{{ blok.name }}</h1>
    <p>{{ blok.intro }}</p>
    <rich-text-renderer
      :document="blok.body"
    />
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  }
}
</script>

As you can see here, we used <rich-text-renderer> to render our Richtext content and class named prose to style it. To make this component work correctly we need to install @marvr/storyblok-rich-text-vue-renderer from Marvin Rudolph, one of our ambassadors.

Bash

yarn add @tailwindcss/typography

To make it work, you have to create two more plugins, composition-api.js and storyblok-rich-text-renderer.js, with the following code.

./plugins/composition-api.js

import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'

Vue.use(VueCompositionApi)

./plugins/storyblok-rich-text-renderer.js

import Vue from 'vue'
import VueRichTextRenderer from '@marvr/storyblok-rich-text-vue-renderer'

// Simple ...
Vue.use(VueRichTextRenderer)

And don't forget to register them in the nuxt.config.js. The order of the plugins is important and the composition-api needs to be registered before the renderer.

./nuxt.config.js

export default {
...
  /*
  ** Plugins to load before mounting the App
  ** https://nuxtjs.org/guide/plugins
  */
  plugins: [
    '~/plugins/components',
    '~/plugins/composition-api.js',
    '~/plugins/storyblok-rich-text-renderer.js'
  ],
...
}

For the last step before rendering the article, we need to configure the dynamic routing in the Nuxt.js. In this case, we just need to create a folder named articles in the pages folder. Add the _slug.vue file into the articles folder with the following code.

./pages/articles/_slug.vue

<template>
  <section>
    <Article :blok="story.content"/>
  </section>
</template>

<script>
import Article from '~/components/Article.vue'

export default {
  components: {
    Article
  },
  data () {
    return {
      story: { content: {} }
    }
  },
  mounted () {
    // Use the input event for instant update of content
    this.$storybridge.on('input', (event) => {
      if (event.story.id === this.story.id) {
        this.story.content = event.story.content
      }
    })
    // Use the bridge to listen the events
    this.$storybridge.on(['published', 'change'], (event) => {
      // window.location.reload()
      this.$nuxt.$router.go({
        path: this.$nuxt.$router.currentRoute,
        force: true,
      })
    })
  },
  asyncData (context) {
    // Load the JSON from the API
    let version = context.query._storyblok || context.isDev ? 'draft' : 'published'

    return context.app.$storyapi.get(`cdn/stories/articles/${context.params.slug}`, {
      version: version
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

You can see in the code that we are using the slug param to decide which content of the article we should request. As soon as we get the data we will render the Article component with the data provided through the blog prop. The source code of this sample could be a lot shorter if we extract the mounted function and asyncData function into the abstract component.

Now create your first article in the article folder, the result in Visual Editor should look like the next image. Don't forget to play a little bit with the content and the real-time visual editor.

Sample of article open in the Visual Editor.
COMMIT:

You can check the current progress in this Github commit.

Showing Featured Articles on the Homepage

At this point in our project, we are missing any listing showing the articles. To do this, we'll create a Featured Articles section on the homepage.

Preparing Storyblok's Structure

We will create a new blok (nested component) called featured-articles containing the field named title of the type Text and the field named articles of the type Multi-option. Set the Source of articles field to Stories and in the field named Path to folder of stories write articles/.

Configuration of the featured articles component.

You can now use the Featured Articles component in the Home story to select a few articles. I strongly recommend creating multiple articles in the articles folder. Use the Add block button in the Home story and select the Featured Articles.

Add new blok to homepage. Select Featured Articles blok. Fill in some content into Featured Articles.

Like before, we are not rendering any result we didn't define for the components in the Nuxt.js. Let's create the following files.

./components/FeaturedArticles.vue

<template>
  <div v-editable="blok">
    <h2 class="pt-2 pl-6 text-lg text-gray-700 italic">{{ blok.title }}</h2>
    <ul class="flex py-6 mb-6">
      <li
        v-for="article in sortedArticles" :key="article._uid"
        class="flex-auto px-6" style="min-width: 33%">
        <article-teaser
          v-if="article.content"
          :article-link="article.full_slug"
          :article-content="article.content"/>
        <p v-else class="px-4 py-2 text-white bg-red-700 text-center rounded">This content loads on save. <strong>Save the entry & reload.</strong></p>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  props: {
    blok: {
      type: Object,
      required: true
    }
  },
  computed: {
    sortedArticles() {
      // Load reference data/content from store
      const featuredArticles = this.$store.state.articles.articles.filter((article) => {
        return this.blok.articles.includes(article.uuid)
      })

      // Enable the ordering of the article previews
      featuredArticles.sort((a, b) => {
        return this.blok.articles.indexOf(a.uuid) - this.blok.articles.indexOf(b.uuid);
      })

      return featuredArticles
    }
  }
}
</script>

./components/ArticlesTeaser.vue

<template>
  <nuxt-link
    :to="articleLink"
    class="article-teaser block py-4 px-6 border rounded border-gray-500">
    <h2 class="pt-2 pb-4 text-2xl font-bold">
      {{ articleContent.name }}
    </h2>
    <p class="pb-6 leading-relaxed">
      {{ articleContent.intro }}
    </p>
  </nuxt-link>
</template>

<script>
export default {
  props: {
    articleContent: {
      type: Object,
      required: true
    },
    articleLink: {
      type: String,
      required: true
    }
  }
}
</script>

<style>
.article-teaser:hover {
  box-shadow: 0px 0px 15px 0px rgba(0,0,0,0.75);
}
</style>

./store/articles.js

export const state = () => ({
  articles: [],
  loaded: '0',
})

export const mutations = {
  setArticles (state, entries) {
    state.articles = entries
  },
  setLoaded (state, loaded) {
    state.loaded = loaded
  }
}

Replace the content of components.js with the following source code:

./plugins/components.js

import Vue from 'vue'
import Page from '~/components/Page.vue'
import Teaser from '~/components/Teaser.vue'
import Grid from '~/components/Grid.vue'
import Feature from '~/components/Feature.vue'
import FeaturedArticles from '~/components/FeaturedArticles.vue'
import ArticleTeaser from '~/components/ArticleTeaser.vue'

Vue.component('page', Page)
Vue.component('teaser', Teaser)
Vue.component('grid', Grid)
Vue.component('feature', Feature)
Vue.component('featured-articles', FeaturedArticles)
Vue.component('article-teaser', ArticleTeaser)

Restart the Nuxt.js to initialize the Store yarn dev and update the ./pages/index.vue with the fetch function to fill the store.

./pages/index.vue

...

<script>
export default {
  ...
  async fetch(context) {
    // Loading reference data - Articles in our case
    if(context.store.state.articles.loaded !== '1') {

      let articlesRefRes = await context.app.$storyapi.get(`cdn/stories/`, { starts_with: 'articles/', version: 'draft' })
      context.store.commit('articles/setArticles', articlesRefRes.data.stories)
      context.store.commit('articles/setLoaded', '1')
    }
  },
  ...
}
</script>

After all of these updates are complete, you should see the following preview of the Home story in the Visual Editor. You can even change the order of the teaser by clicking the Show only selected button and drag & drop the articles.

Preview of Featured Articles in Visual Editor.
COMMIT:

You can check the current progress in this Github commit.

Creating an Articles Overview Page

Let's quickly create an overview page of our articles before we implement another language. Create index.vue on the path ./pages/articles with the following source code.

./pages/articles/index.vue

<template>
  <section>
    <h2 class="py-10 text-center font-bold text-4xl">Articles Overview</h2>
    {{ articles }}
    <ul class="flex py-6 mb-6">
      <li
        v-for="article in stories" :key="article._uid"
        class="flex-auto px-6" style="min-width: 33%">
        <article-teaser
          v-if="article.content"
          :article-link="article.full_slug"
          :article-content="article.content"/>
        <p v-else class="px-4 py-2 text-white bg-red-700 text-center rounded">This content loads on save. <strong>Save the entry & reload.</strong></p>
      </li>
    </ul>
  </section>
</template>

<script>
export default {
  data () {
    return {
      stories: []
    }
  },
  asyncData (context) {
    return context.app.$storyapi.get('cdn/stories', {
      starts_with: 'articles/',
      version: 'draft'
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

We need to also update the Header.vue to point to this path. Replace the source code of Header.vue with the following lines.

./components/Header.vue

<template>
  <header class="max-w-5xl mx-auto py-8 flex">
    <nav>
      <ul>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/">
            Home
          </nuxt-link>
        </li>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/articles">
            Blog
          </nuxt-link>
        </li>
      </ul>
    </nav>
    <div class="flex-1">
      <a href="/" class="block w-56 mx-auto">
        <img src="http://a.storyblok.com/f/42016/1096x313/0353bf6654/logo2.png">
      </a>
    </div>
    <nav>
      <ul>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/">
            English
          </nuxt-link>
        </li>
        <li>
          <nuxt-link
            class="text-teal-600 hover:underline"
            to="/de">
            German
          </nuxt-link>
        </li>
      </ul>
    </nav>
  </header>
</template>

Our navigation should work now and therefore we may implement another language.

COMMIT:

You can check the current progress in this Github commit.

Adding Another Language

We can implement internationalization with two different approaches in Storyblok. It strongly depends on your use case, which you should use. Read more about the Internationalization in docs. We are going to use Field Level Translation.

All you need to do on the Storyblok side is go to the Settings of your space and define a new language in the Languages tab. Let's add German as a language and set English as the default language.

Configuration of another language using the Field Level Translation.

If you open any story in the Visual Editor now, you will see the language dropdown in the header.

Open language dropdown in Visual Editor.

If you choose German language right now, you won't see any changes because we didn't set any of the fields in our components as translatable. To do that name the field headline of the Teaser as translatable.

Setting headline as translatable field.

Change the language to German and you will see a Translate checkbox next to the translatable field. If you set the checkbox to true you are able to translate the value and you will see a real-time preview in the Preview. If the checkbox stays false, the default value will be used.

Sample of translating the field.

The Storyblok Visual Editor works, but the Nuxt.js routing needs a little adjustment to make it work correctly. I will keep it simple for the purpose of this tutorial and we will create a de folder in the pages folder of the Nuxt.js. Create following files in this folder with the following code.

./pages/de/index.vue

<template>
  <section>
    <component
      v-if="story.content.component"
      :key="story.content._uid"
      :blok="story.content"
      :is="story.content.component" />
  </section>
</template>

<script>
export default {
  data () {
    return {
      story: { content: {} }
    }
  },
  mounted () {
    // Use the input event for instant update of content
    this.$storybridge.on('input', (event) => {
      if (event.story.id === this.story.id) {
        this.story.content = event.story.content
      }
    })
    // Use the bridge to listen the events
    this.$storybridge.on(['published', 'change'], (event) => {
      // window.location.reload()
      this.$nuxt.$router.go({
        path: this.$nuxt.$router.currentRoute,
        force: true,
      })
    })
  },
  async fetch(context) {
    // Loading reference data - Articles in our case
    if(context.store.state.articles.loaded !== '1') {

      let articlesRefRes = await context.app.$storyapi.get(`cdn/stories/`, { starts_with: 'de/articles/', version: 'draft' })
      context.store.commit('articles/setArticles', articlesRefRes.data.stories)
      context.store.commit('articles/setLoaded', '1')
    }
  },
  asyncData (context) {
    // // This what would we do in real project
    // const version = context.query._storyblok || context.isDev ? 'draft' : 'published'
    // const fullSlug = (context.route.path == '/' || context.route.path == '') ? 'home' : context.route.path

    // Load the JSON from the API - loadig the home content (index page)
    return context.app.$storyapi.get('cdn/stories/de/home', {
      version: 'draft'
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

./pages/de/articles/index.vue

<template>
  <section>
    <h2 class="py-10 text-center font-bold text-4xl">Articles Overview</h2>
    {{ articles }}
    <ul class="flex py-6 mb-6">
      <li
        v-for="article in stories" :key="article._uid"
        class="flex-auto px-6" style="min-width: 33%">
        <article-teaser
          v-if="article.content"
          :article-link="article.full_slug"
          :article-content="article.content"/>
      </li>
    </ul>
  </section>
</template>

<script>
export default {
  data () {
    return {
      stories: []
    }
  },
  asyncData (context) {
    return context.app.$storyapi.get('cdn/stories', {
      starts_with: 'de/articles/',
      version: 'draft'
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

./pages/de/articles/_slug.vue

<template>
  <section>
    <Article :blok="story.content"/>
  </section>
</template>

<script>
import Article from '~/components/Article.vue'

export default {
  components: {
    Article
  },
  data () {
    return {
      story: { content: {} }
    }
  },
  mounted () {
    // Use the input event for instant update of content
    this.$storybridge.on('input', (event) => {
      if (event.story.id === this.story.id) {
        this.story.content = event.story.content
      }
    })
    // Use the bridge to listen the events
    this.$storybridge.on(['published', 'change'], (event) => {
      // window.location.reload()
      this.$nuxt.$router.go({
        path: this.$nuxt.$router.currentRoute,
        force: true,
      })
    })
  },
  asyncData (context) {
    // Load the JSON from the API
    let version = context.query._storyblok || context.isDev ? 'draft' : 'published'

    return context.app.$storyapi.get(`cdn/stories/de/articles/${context.params.slug}`, {
      version: version
    }).then((res) => {
      return res.data
    }).catch((res) => {
      if (!res.response) {
        console.error(res)
        context.error({ statusCode: 404, message: 'Failed to receive content form api' })
      } else {
        console.error(res.response.data)
        context.error({ statusCode: res.response.status, message: res.response.data })
      }
    })
  }
}
</script>

Now that all the routes are working, the last piece of code we need to update is to make the Header navigation dynamic, but it is up to you. You can visit http://localhost:3000/de/articles to browse your articles in the german language.

COMMIT:

You can check the current progress in this Github commit.

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 (previously Now.sh) and deploy using the command line.

First, create an account on Vercel and install their CLI application. Then, create a vercel.json file with the following content.

./vercel.json

{
  "builds": [
    {
      "src": "nuxt.config.js",
      "use": "@nuxtjs/vercel-builder",
      "config": {}
    }
  ]
}

Deploy your website by running teh vercel in your console.

bash

vercel
COMMIT:

You can check the current progress in this Github commit.

Conclusion

It's incredibly easy to build a full-blown website with Nuxt.js and it comes with a great ecosystem. I really like the way Nuxt.js abstracts common tasks you normally do in the Webpack configuration. It feels a little bit like Ruby on Rails where conventions go over configuration. For big projects, these conventions make it easy to onboard new team members and make the projects a lot more maintainable.

ResourceLink
Github repository of this Tutorialgithub.com/storyblok/nuxtjs-multilanguage-website
Vercelvercel.com
Nuxt.jsnuxtjs.org
Vue.jsvuejs.org
Storyblok Appapp.storyblok.com

About the author

Samuel Snopko

Samuel Snopko

Samuel is the Head of Developer Relations at Storyblok with a passion for JAMStack and beautiful web. Co-creator of DX Meetup Basel and co-organizer of Frontend Meetup Freiburg. Recently fell in love with Vue.js, Nuxt.js, and Storyblok.