Multi-Language Blog with Nuxt content module.

alvarosabu

Alvaro Saburido

Posted on July 2, 2020

Multi-Language Blog with Nuxt content module.

Last week I managed to refactor my personal portfolio done using Nuxt alvarosaburido.com with 3 major features that the awesome team from Nuxt.js released recently:

  • Nuxt Full Static v2.13
  • @nuxt/content module here
  • @nxut/components module Github repo

The most game-changing is the Content Module that allows you to fetch your markdown files through a MongoDB like API, acting as a Git-based Headless CMS.

Today in this tutorial I gonna help you create your own multi-language blog using @nuxt/content.

Setup

Let's create our new project using the official scaffolding tool create-nuxt-app.

With yarn:



yarn create nuxt-app nuxt-i18n-blog


Enter fullscreen mode Exit fullscreen mode

It will ask you some config questions, when you prompt with Nuxt.js modules option, you can select the content module directly:

Nuxt-create-app

Note: I will be using TailwindCSS to prototype faster the components used in this tutorial, if you need more info about it here are two nice articles about it: Building my site with Tailwind CSS & How to use Tailwind CSS with Nuxt.js

After all the installation:



cd nuxt-i18n-blog

npm run dev


Enter fullscreen mode Exit fullscreen mode

Nuxt-i18n

To do a multi-language blog the first thing you need is to add i18n (Internationalization) module to your app. Of course. Nuxt already has an excellent module Nuxt-i18n which handles all the heavy lifting such as automatically generate routes prefixed with locale code and SEO support.



yarn add nuxt-i18n


Enter fullscreen mode Exit fullscreen mode

Then add the module to nuxt.config.js:



{
  modules: [
    [
      'nuxt-i18n',
       { /* module options */ }
    ]
  ],

  // Or with global options
  i18n: {}
}


Enter fullscreen mode Exit fullscreen mode

Now let's add the languages we want to support in the config, for this exercise we will create content in English (default), Spanish & French.



{
  modules: [
    [
      'nuxt-i18n',
       {
         locales: [
           {
              code: 'es',
              iso: 'en-ES',
              name: 'Español',
            },
            {
              code: 'en',
              iso: 'en-US',
              name: 'English',
            },
            {
              code: 'fr',
              iso: 'fr-fr',
              name: 'Français',
            },
         ],
         defaultLocale: 'en',
         noPrefixDefaultLocale: true,
       }
    ]
  ],
}


Enter fullscreen mode Exit fullscreen mode

The noPrefixDefaultLocale means that the module won't generate local prefixed routes, example defaultLocale: 'en' yourblog.com/en/articles would be yourblog.com/articles instead.

Next thing is to add the json files for the locale messages, under locales folder and we add them to the nuxt-config.js like this:



'nuxt-i18n',
  {
    //  locales: [..].
    //
        vueI18n: {
      fallbackLocale: 'en',
      messages: {
        en: require('../locales/en-us.json'),
        es: require('../locales/es-es.json'),
        fr: require('../locales/fr-fr.json'),
      },
    },
  },


Enter fullscreen mode Exit fullscreen mode

Content

Before digging in the directory structure for a multi-language blog that I used for my portfolio, I must say there is no standard way to do it, my recommendations are based on previous experience working with CMS technologies for the past 6 years and how they organize the content for several languages.

For the sake of this tutorial I recommend you follow the same structure so, it makes sense and is easy to follow, but for your own blog find the best way that adapts to your preferences and use cases.

The core idea is to have routes following:



<domain>/<local>/<parent-route>/<child-route>


Enter fullscreen mode Exit fullscreen mode

Like for example nuxt-i18n-blog/es/blog/code-article . It mimics the structure in the content directory so everything comes together smoothly when we proceed to fetch the data.



nuxt-i18n-blog
└── 📁 content
    ├── 📁 en
    │   └── 📁 blog
    │       └── 📄 my-first-article.md
            └── 📄 my-second-article.md
    ├── 📁 es
    │   └── 📁 blog
    │       └── 📄 mi-primer-articulo.md
    ├── 📁 fr
    │   └── 📁 blog
    │       └── 📄 mon-premier-article.md
    └── 📁 ca
        └── 📁 blog
            └── 📄 meu-primer-article.md


Enter fullscreen mode Exit fullscreen mode

The cool thing is that @nuxt/content supports FrontMatter by default, meaning you can add metadata to your article like this:



---
title: "My first article"
category: web
description: "Step by step tutorial on how to stop being sad and being awesome instead."
media: https://images.unsplash.com/photo-1592500103620-1ab8f2c666a5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=3000&q=80
---

Your content


Enter fullscreen mode Exit fullscreen mode

For the article's media images, I found a really cool account inside unsplash.com called @morningbrew with awesome quality images that will look nice as placeholders for your tests.

The Blog

So now that we have configured the locales and the content structure, we can start creating our pages. The idea will be to have:

  • /blog view containing our blog feed.
  • A header with language switching links
  • /blog/_slug with the article itself.

Blog Feed

Let's dig in.

Feed

For the blog page, under /pages create a blog/index.vue (In Nuxt, instead of creating Blog.vue we create a index.vue inside of a directory called blog, as it's explained here)



//  blog/index.vue

<script>
export default {
  name: 'Blog',
  async asyncData(context) {
    const { $content, app } = context;
    const posts = await $content(`${app.i18n.locale}/blog'`).fetch();

    return {
      posts,
    }
  },
}
</script>


Enter fullscreen mode Exit fullscreen mode

The asyncData is called every time before loading the page component, it receives the context. It's pretty handy when it comes to fetch some data before the lifecycle hooks.

Inside the context, you will notice there is a $content instance, (Same as this.$content if you decide to use it outside of asyncData).



const posts = await $content(`${app.i18n.locale}/blog'`).fetch();


Enter fullscreen mode Exit fullscreen mode

Since is an async function, you need to add await, it accept a path which is essentially the same path structure we used for the route.

You might think, why not use the route inside the context instead of computing the route using the current locale?. You can totally use await $content(context.route.path).fetch() but you will need to deactivate the noPrefixDefaultLocale in the nuxt.config.js because you will not have the locale prefix in the route for the default locale. Or, remove the currentLocale directory inside of /content directory, but is not really scalable in case you want to change you default locale later.

There are a lot of cool options like filtering and sorting methods to use along fetch() so make sure you check them out here 😜.

As a result we will have an array with all the articles fetched, the cool thing is that you will have all the metadata from your .md as properties in each post instance.

Content fetch using @nuxt/content

Excited

Now let's add a template to show some cards with the articles we just fetch



<template>
  <div class="blog container mx-auto">
    <section class="grid grid-cols-3 gap-4 pt-12">
      <article
        class="post max-w-sm rounded overflow-hidden shadow-lg flex flex-col"
        v-for="(post, $index) in posts"
        :key="`post-${$index}`"
      >
        <img class="w-full" :src="post.media" :alt="post.title" />
        <div class="px-6 py-4 flex-2">
          <h3>{{ post.title }}</h3>
          <p class="text-gray-700 text-base">
            {{ post.description }}
          </p>
        </div>
        <footer class="p-4">
          <nuxt-link :to="post.path" class="font-bold text-xl mb-2">
            <button :to="post.path" class="btn btn-teal">
              {{ $t('read-more') }}
            </button>
          </nuxt-link>
        </footer>
      </article>
    </section>
  </div>
</template>


Enter fullscreen mode Exit fullscreen mode

PD: For the sake of this tutorial, I added a second article in the default locale to actually see more posts on the Blog page so it looks better.

Language Switching

Under the /components directory, create a Header.vue:



<template>
  <header class="topnav h-54px p-2 border-b-2 border-gray-200">
    <div class="container mx-auto flex justify-between items-center">
      <div class="right flex justify-start items-center">
        <Logo class="w-8 h-8 mr-3" />
        <h1 class="text-lg font-bold text-gray-700">{{ title }}</h1>
      </div>
      <div class="left self-end">
        <ul class="flex lang-switch justify-around">
          <!-- ...Here goes the language-switch -->
        </ul>
      </div>
    </div>
  </header>
</template>


Enter fullscreen mode Exit fullscreen mode

What is pretty important is what it's inside of the language-switch using switchLocalePath('en') in the :to prop for <nuxt-link>



<ul class="flex lang-switch justify-around">
  <li v-if="$i18n.locale !== 'en'">
    <nuxt-link class="text-md ...":to="switchLocalePath('en')">EN</nuxt-link>
  </li>
  <li v-if="$i18n.locale !== 'es'">
    <nuxt-link class="text-md ..." :to="switchLocalePath('es')">ES</nuxt-link>
  </li>
  <li v-if="$i18n.locale !== 'fr'">
    <nuxt-link class="text-md ..." :to="switchLocalePath('fr')">FR</nuxt-link>
  </li>
</ul>


Enter fullscreen mode Exit fullscreen mode

Then this component is ready to be added globally in layouts/default.vue just above the <Nuxt />. This way you will have it available in all your routes that use this layout.



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

<script>
export default {
  name:'default'
}
</script>


Enter fullscreen mode Exit fullscreen mode

Try clicking each link to see how your route changes to the selected locale. You should see how the page shows the correct posts for each language like this:

Switching Languages

If we click any of the posts Read more buttons we will be routed to a child page that we are going to build in the next section. But before continuing there is a catch.

When we define the button:



<nuxt-link :to="post.path" class="font-bold text-xl mb-2">
  <button :to="post.path" class="btn btn-teal">
    {{ $t('read-more') }}
  </button>
</nuxt-link>


Enter fullscreen mode Exit fullscreen mode

We are passing the Post's path directly, if you are using noPrefixDefaultLocale, @nuxt/content will still give you the path with the corresponding locale prefix (this is because we build our content directory this way) so it will try to access a route it doesn't exist whenever you are in the default locale, leading to a page like this:

Page not found

Don't worry, it's pretty easy to solve. Go back to the Blog component and map the posts returning from the $content fetch like this:



const defaultLocale = app.i18n.locale;
const posts = await $content(`${defaultLocale}/blog`).fetch();

return {
  posts: posts.map(post => ({
    ...post,
    path: post.path.replace(`/${defaultLocale}`, ''),
  })),
};


Enter fullscreen mode Exit fullscreen mode

This should be enough to replace all the post paths containing the default locale.

Content Page

Now comes the fun part 😜. Until now we have created the basic structure and config + our Blog page and a nice language switcher, it's time to create the actual content page for our article.

Inside of pages/blog/ create a folder _slug with an index.vue inside:



<script>
export default {
  name: 'Post',
  async asyncData(context) {
    const { $content, params, app, route, redirect } = context;
    const slug = params.slug;
    const post = await $content(`${app.i18n.locale}/blog`, slug).fetch();

    return {
      post,
    }
  },
}
</script>


Enter fullscreen mode Exit fullscreen mode

Similar to what we did on the Blog component, we fetch the post trough a $content instance, but this time passing the slug (es/blog/<slug>) obtained from the route parameters.

For the template, we have an special component called <nuxt-content /> that requires the post to pass using the prop :document like this:



<template>
  <div class="container mx-auto pt-6">
    <article v-if="post">
      <nuxt-content class="text-gray-800" :document="post" />
    </article>
  </div>
</template>


Enter fullscreen mode Exit fullscreen mode

<nuxt-content> component will automatically add a .nuxt-content class, you can use it to customize your styles:



.nuxt-content h1 {
  /* my custom h1 style */
}


Enter fullscreen mode Exit fullscreen mode

To add more juice to the content page let's take advantage of the props available using Frontmatter.



<template>
  <div class="container mx-auto pt-6">
    <article v-if="post">
      <header class="grid grid-cols-2 gap-4 mb-12 rounded shadow-lg p-4">
        <img :src="post.media" alt="post.title" />
        <div class="">
          <h2 class="text-lg font-bold text-gray-800 mb-2">{{ post.title }}</h2>
          <p class="text-sm text-gray-700">
            {{ $t('published-at') }} {{ getDate }}
          </p>
        </div>
      </header>
      <nuxt-content class="text-gray-800" :document="post" />
    </article>
  </div>
</template>


Enter fullscreen mode Exit fullscreen mode

This will give us a nice header with the image, the title, and the createdAt we get from a computed property called getDate, for this, date-fns will be our best friend-



import { format } from 'date-fns';

const computed = {
  getDate() {
    return format(new Date(this.post.createdAt), 'dd/MM');
  },
};


Enter fullscreen mode Exit fullscreen mode

Post article

But how about metadata? Nuxt.js uses vue-meta under the hood to update headers and html attributes in each page.

To do this in the Content Page add the head method to your component, take notice of the htmlAttrs lang where we set the current locale. The rest is just Open Graph meta.



export default {
  name: 'post',
  head() {
    return {
      title: this.post.title,
      htmlAttrs: {
        lang: this.$i18n.locale,
      },
      meta: [
        {
          hid: 'og:description',
          property: 'og:description',
          content: this.post.description,
        },
        {
          property: 'og:title',
          hid: 'og:title',
          content: this.post.title,
        },
        {
          hid: 'og:image',
          property: 'og:image',
          content: this.post.media,
        },
      ],
    };
  },
};


Enter fullscreen mode Exit fullscreen mode

Inspecting the social preview of your page (I use a chrome extension called Social Share Preview) we can see the metadata is correctly working with the post information.

Language switching inside post

But wait? What happens if I try to change the language when I'm inside of the Content Page? Yep, you guessed right, I will not work, and here is why.

If you remember well, we use <nuxt-link class="text-md ..." :to="switchLocalePath('es')">ES</nuxt-link> for the language-switcher which will take the current path and change the prefix of the route. The problem is that if you change blog/my-first-article to, let's say, Spanish, the route would be /es/blog/my-first-article, this route doesn't exist in our content structure because we the slug corresponds to the file name, so for Spanish version should be /es/blog/mi-primer-articulo.

There are many strategies to solve this:

  • Adding the other languages routes in the Frontmatter of each article and use a .catch() when fetching data to redirect to current locale version.
  • Adding a uid in the Frontmatter unique for all the locales version of the article, and then use this to fetch the correct article
  • Same as the first one but instead of using catch, just hide the language switcher on Content Pages and add the links manually on the post.

For my blog I opted for the second solution which I think is cleaner, step 1 you just need to add in your Header component a condition to hide at content pages (you can detect it by checking if the route has a slug param).



// in the template
<template>
  <ul class="flex lang-switch justify-around" v-if="!isContentPage">
    <!-- ...-->
  </ul>
</template>;

// in the component script
export default {
  name: 'Header',
  data,
  computed: {
    isContentPage() {
      return this.$route.name.includes('slug');
    },
  },
};


Enter fullscreen mode Exit fullscreen mode

In your articles Frontmatter section, add an array of objects containing the paths of the other locales versions of the article



//content/en/blog/my-first-article.md

title: My first article
category: web
description: Step by step tutorial on how to stop being sad and being awesome instead.
media: https://images.unsplash.com/photo-1592500103620-1ab8f2c666a5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=3000&q=80
otherLanguages:

- locale: es
  path: /es/mi-primer-articulo
- locale: fr
  path: /fr/mon-premier-article

---


Enter fullscreen mode Exit fullscreen mode

Next let's add the logic to pages/_slug/index



// in the template
<template>
<!-- ... -->
  <p class="text-sm text-gray-700">
    {{ $t('also-available-in') }}
    <nuxt-link
      class="uppercase text-teal-600 hover:text-teal-800"
      v-for="lang in otherLanguages"
      :key="lang.locale"
      :to="lang.path"
    >
      {{ lang.locale }}
    </nuxt-link>
  </p>
<!-- ... -->
</template>

// in the component script
export default {
  name: 'Header',
  data,
  computed: {
    // ...
    otherLanguages() {
      return this.post.otherLanguages || []
    },
  }
}


Enter fullscreen mode Exit fullscreen mode

And voilá. Now you have a nice multi-language blog.

Change language inside post

If you enjoy the article, you have questions or any feedback let's talk in the comments 💬.

Happy coding!.

💖 💪 🙅 🚩
alvarosabu
Alvaro Saburido

Posted on July 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related