How to build a Jamstack multi-language blog with Nuxt.js
Tags: vue, nuxt, javascript, tutorial
Published: 2020-05-21 23:32:05

[Jamstack](https://jamstack.org/) (Javascript, APIs and Markup Stack) is a terminology around the new way of making web projects where you don’t have to host your own backend that builds the site every time you serve it, instead it renders out a set of static pages at build time and deploys them to a content delivery network (CDN). This means better security, increased scalability and improved website performance.

In this tutorial you'll learn how to build a Jamstack multilanguage blog using [Nuxt.js](https://nuxtjs.org/), a powerful Vue framework that supports SPA, SSR and statically generated renderings in conjunction with [Strapi Headless CMS](https://strapi.io/) to store data and expose them to generate a static blog. To setup Strapi locally you can follow [this guide](https://strapi.io/documentation/3.0.0-beta.x/installation/cli.html) otherwise you can use a read only instance running on our server at [https://strapi.lotrek.net/](https://strapi.lotrek.net/).

👉🏻 You can find the complete code of this tutorial in [this repository](https://github.com/astagi/nuxt-strapi-i18n).

## Backend structure

With Strapi I built a naive structure to support translations with a `Post` table containing elements linked with one or more `TransPost` elements that contain translations

```plain
       ____________                        ____________
      |    POST    |                      | TRANS_POST |
       ============                        ============
      | published  |                      | language   |
      | created_at | <--(1)-------(N)-->> | title      |
      |            |                      | content    |
      |            |                      | slug       |
       ============                        ============
```

You can play with it using [GraphQL playground](https://strapi.lotrek.net/graphql) and explore the backend. Remember that the main focus of this tutorial is `Nuxt.js`, you can use any backend you want to generate the final static site. Backend repository is available [here](https://github.com/astagi/nuxti18n-strapi-backend)

## Setup Nuxt.js project

Install Nuxt.js globally and create a new app called `multilangblog`

```sh
npx create-nuxt-app multilangblog
```

Remember to select `axios` option (you'll need it later) and add a UI framework such as [Buefy](https://buefy.org/).

## Create a client to fetch posts

Install `apollo-fetch` client to fetch posts from the Strapi server (I used this old package to keep the client part as simple as possible, check [@nuxtjs/apollo](https://github.com/nuxt-community/apollo-module) for a more structured and newer plugin)

```sh
yarn add apollo-fetch
```

and create `index.js` file under `services` folder to wrap all the queries. This client should implement 3 methods:

- `getAllPostsHead`: fetches all the posts in a specific language, showing `slug` and `title`.
- `getAllPosts`: fetches all the posts in a specific language, showing `slug`, `title`, `content` and the other posts slugs in other languages to get alternate urls.
- `getSinglePost`: fetch a single post with a specific slug and language, showing all the attributes and posts in other languages.

```js
import { createApolloFetch } from 'apollo-fetch'

export default class BlogClient {
  constructor () {
    this.apolloFetch = createApolloFetch({ uri: `${process.env.NUXT_ENV_BACKEND_URL}/graphql` })
  }

  getAllPostsHead (lang) {
    const allPostsQuery = `
      query AllPosts($lang: String!) {
        transPosts(where: {lang: $lang}) {
          slug
          title
        }
      }
    `
    return this.apolloFetch({
      query: allPostsQuery,
      variables: {
        lang
      }
    })
  }

  getAllPosts (lang) {
    const allPostsQuery = `
      query AllPosts($lang: String!) {
        transPosts(where: {lang: $lang}) {
          slug
          title
          content
          post {
            published
            transPosts(where: {lang_ne: $lang}) {
              slug
              lang
            }
          }
        }
      }
    `
    return this.apolloFetch({
      query: allPostsQuery,
      variables: {
        lang
      }
    })
  }

  getSinglePost (slug, lang) {
    const simplePostQuery = `
      query Post($slug: String!, $lang: String!) {
        transPosts(where: {slug : $slug, lang: $lang}) {
          slug
          title
          content
          post {
            published
            transPosts(where: {lang_ne: $lang}) {
              slug
              lang
            }
          }
        }
      }
    `
    return this.apolloFetch({
      query: simplePostQuery,
      variables: {
        slug,
        lang
      }
    })
  }
}
```

To make `BlogClient` available whenever you have access to the context (e.g. in `asyncData` function) create `plugins/ctx-inject.js` file

```js
import BlogClient from '~/services'

export default ({ app }, inject) => {
  app.$blogClient = new BlogClient()
}
```

and add it to `plugins` in `nuxt.config.js`

```js
export default {
  // ...
  plugins: ['~/plugins/ctx-inject.js']
}
```

## Create the main views

The structure of this blog will be really simple, in the homepage (`/`) there'll be a list of posts with a link to read the article (`/blog/<postslug>`). Now that you can access the `BlogClient` instance from the context, start rewriting the `HomePage` component (`pages/index.vue`) to fetch blog posts in a special method called [asyncData](https://nuxtjs.org/guide/async-data) and render title and link for each post. `asyncData` receives the [context](https://nuxtjs.org/api/context) as the first argument and your `BlogClient` instance is accessible at `context.app.$blogClient`

```html
<template>
  <section class="section">
    <div class="is-mobile">
      <div v-for="post in posts" :key="post.slug">
        <h2>{{ post.title }}</h2>
        <nuxt-link :to="{name: 'blog-slug', params:{slug: post.slug}}">Read more...</nuxt-link>
      </div>
    </div>
  </section>
</template>

<script>
export default {
  name: 'HomePage',
  async asyncData ({ app }) {
    const postsData = await app.$blogClient.getAllPostsHead('en')
    return { posts: postsData.data.transPosts }
  },
  data () {
    return {
      posts: []
    }
  }
}
</script>
```

Add `/blog/<postslug>` route creating the component `BlogPost` (`pages/blog/_slug.vue`). Install [Vue Markdown component](https://github.com/miaolz123/vue-markdown) to render the article correctly (`yarn add vue-markdown`)

```html
<template>
  <section class="section">
    <div class="is-mobile">
      <h2>{{ post.title }}</h2>
      <vue-markdown>{{ post.content }}</vue-markdown>
    </div>
  </section>
</template>

<script>
export default {
  name: 'BlogPost',
  components: {
    'vue-markdown': VueMarkdown
  },
  async asyncData ({ app, route }) {
    const postsData = await app.$blogClient.getSinglePost(route.params.slug, 'en')
    return { post: postsData.data.transPosts[0] }
  },
  data () {
    return {
      post: null
    }
  }
}
</script>
```

## Add i18n

To setup i18n install [Nuxt i18n module](https://nuxt-community.github.io/nuxt-i18n/basic-usage.html)

```sh
yarn add nuxt-i18n
```

Enable it in the `module` section of `nuxt.config.js` file

```js
{
  modules: ['nuxt-i18n']
}
```

and setup i18n

```js
const LOCALES = [
  {
    code: 'en',
    iso: 'en-US'
  },
  {
    code: 'es',
    iso: 'es-ES'
  },
  {
    code: 'it',
    iso: 'it-IT'
  }
]
const DEFAULT_LOCALE = 'en'

export default {
  // ...
  i18n: {
    locales: LOCALES,
    defaultLocale: DEFAULT_LOCALE,
    encodePaths: false,
    vueI18n: {
      fallbackLocale: DEFAULT_LOCALE,
      messages: {
        en: {
          readmore: 'Read more'
        },
        es: {
          readmore: 'Lee mas'
        },
        it: {
          readmore: 'Leggi di più'
        }
      }
    }
  }
  // ...
}
```

Now you can modify the `HomePage` component: in `nuxt-link` you should use `localePath` and render the translated label `readmore` using `$t`

```html
<nuxt-link :to="localePath({name: 'blog-slug', params:{slug: post.slug}})">{{ $t('readmore') }}</nuxt-link>
```

In `asyncData` you can fetch the posts list using the `store.$i18n` attribute of `context` to get the current language.

```js
// ....
async asyncData ({ app, store }) {
  const postsData = await app.$blogClient.getAllPostsHead(
    store.$i18n.locale
  )
  return { posts: postsData.data.transPosts }
},
// ....
```

Do the same in `BlogPost` component using `route.params.slug` to get the slug parameter

```js
// ....
async asyncData ({ app, route, store }) {
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  return { post: postsData.data.transPosts[0] }
},
// ....
```

It's time to create a component to switch the current language, `LanguageSwitcher` (`components/LanguageSwitcher.vue`)

```html
<template>
  <b-navbar-dropdown :label="$i18n.locale">
    <nuxt-link v-for="locale in availableLocales" :key="locale.code" class="navbar-item" :to="switchLocalePath(locale.code)">
      {{ locale.code }}
    </nuxt-link>
  </b-navbar-dropdown>
</template>

<script>
export default {
  computed: {
    availableLocales () {
      return this.$i18n.locales.filter(locale => locale.code !== this.$i18n.locale)
    }
  }
}
</script>
```

and include it in `layouts/default.vue` to make it available in the navbar. This component calls `switchLocalePath` to get a link to the current page in another language. To make the language switcher working with dynamic routes you need to set the `slug` parameter in `BlogPost` component using [store.dispatch](https://nuxt-community.github.io/nuxt-i18n/lang-switcher.html#dynamic-route-parameters)

```js
//...
async asyncData ({ app, route, store }) {
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  await store.dispatch(
    'i18n/setRouteParams',
    Object.fromEntries(postsData.data.transPosts[0].post.transPosts.map(
      el => [el.lang, { slug: el.slug }])
    )
  )
  return { post: postsData.data.transPosts[0] }
},
//...
```

👉🏻 [More on language switcher](https://nuxt-community.github.io/nuxt-i18n/lang-switcher.html)

Remember to set `NUXT_ENV_BACKEND_URL` environment variabile used by `BlogClient` with .env or directly (`export NUXT_ENV_BACKEND_URL=https://strapi.lotrek.net`) and launch the development server

```sh
yarn dev
```

## Full static generation

👉🏻 Note that I wrote this article using Nuxt.js 2.12.0, then I upgraded the core to 2.13.0 to use full static generation, be sure to run the latest version. For more information please read [Going Full Static](https://nuxtjs.org/blog/going-full-static/) from the official Nuxt.js blog and follow the [changes in the repository](https://github.com/astagi/nuxt-strapi-i18n/commits/master).

To generate a full static version of this blog with Nuxt.js add `target: 'static'` to `nuxt.config.js` and run

```sh
nuxt build && nuxt export
```

(you can wrap `nuxt export` in the script section of `package.json`)

The final output is a list of generated routes inside `dist` folder

```plain
ℹ Generating output directory: dist/                                                                                       
ℹ Full static mode activated                                                                                               
ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
ℹ Ready to run nuxt serve or deploy dist/ directory
✨  Done in 43.49s.
```

👉🏻 Starting from version 2.13.0 Nuxt.js uses a `crawler` to detect every relative link and generate it. You can disable the crawler setting `generate.crawler: false` and still add dynamic routes by your own for performance reasons (as in this case) or to add extra routes that the crawler could not detect.

To add dynamic routes manually you have to implement `routes` function under `generate` settings in `nuxt.config.js` and return a list of objects containing the `route` you want to generate and the `payload` containing the post.

```js
import BlogClient from './services'

// ...

export default {
  // ...
  crawler: false,
  generate: {
    routes: async () => {
      const client = new BlogClient()
      let routes = []
      let postsData = []
      for (const locale of LOCALES) {
        postsData = await client.getAllPosts(locale.code)
        routes = routes.concat(postsData.data.transPosts.map((post) => {
          return {
            route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}/blog/${post.slug}`,
            payload: post
          }
        }))
      }
      return routes
    }
  }
  //...
}
```

Since `payload` is available in the `context`, you can refactor asyncData function in `BlogPost` component to get the specific post from `context.payload`

```js
const getSinglePostFromContext = async ({ app, route, store, payload }) => {
  if (payload) {
    return payload
  }
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  return postsData.data.transPosts[0]
}

export default {
  name: 'BlogPost',
  async asyncData (context) {
    const singlePost = await getSinglePostFromContext(context)
    await context.store.dispatch(
      'i18n/setRouteParams',
      Object.fromEntries(singlePost.post.transPosts.map(
        el => [el.lang, { slug: el.slug }])
      )
    )
    return { post: singlePost }
  },
  // ...
}
```

Run `nuxt build && nuxt export` again

```plain
ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
✔ Generated /blog/hello-world
✔ Generated /it/blog/ciao-mondo
✔ Generated /es/blog/hola-mundo
ℹ Ready to run nuxt serve or deploy dist/ directory
✨  Done in 33.82s.
```

Now Nuxt.js is able to generate dynamic routes 🎉

You can test your static site installing using

```sh
nuxt serve
```

Sometimes you may need to configure a custom path for a dynamic route, for example you may want to keep `/blog/:slug` path for english, `/artículos/:slug` route for spanish and `/articoli/:slug` route for italian. Following nuxt-i18n documentation you have to specify these routes in `i18n` section of `nuxt.config.js`

```js
i18n {
  // ...
  parsePages: false,
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  },
  // ...
}
```

To make these settings reusable both in `i18n` configuration and `generate` function, move custom routes in a separated file `i18n.config.js`

```js
export default {
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  }
}
```

and import it in `nuxt.config.js`

```js
import i18nConfig from './i18n.config'

// ...

export default {
  // ...
  i18n: {
    locales: LOCALES,
    defaultLocale: DEFAULT_LOCALE,
    parsePages: false,
    pages: i18nConfig.pages,
    encodePaths: false,
    vueI18n: {
      fallbackLocale: DEFAULT_LOCALE,
      // ...
    }
  },
  // ...
```

now you can rewrite `generate` function getting the correct path from the custom configuration

```js
routes: async () => {
  const client = new BlogClient()
  let routes = []
  let postsData = []
  for (const locale of LOCALES) {
    postsData = await client.getAllPosts(locale.code)
    routes = routes.concat(postsData.data.transPosts.map((post) => {
      return {
        route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}${i18nConfig.pages['blog/_slug'][locale.code].replace(':slug', post.slug)}`,
        payload: post
      }
    }))
  }
  return routes
}
```

Build and export everything again and you'll get

```plain
ℹ Generating pages
✔ Generated /blog/hello-world
✔ Generated /it/articoli/ciao-mondo
✔ Generated /es/artículos/hola-mundo
✔ Generated /es/
✔ Generated /it/
✔ Generated /
ℹ Ready to run nuxt serve or deploy dist/ directory
✨  Done in 33.82s.
```

Your full static generated blog with custom paths is ready 🎉

## You can do more

In [this repository](https://github.com/astagi/nuxt-strapi-i18n) you can see the complete code of this tutorial, the result is deployed on [Netlify CDN](https://www.netlify.com/) at [https://eager-shockley-a415b7.netlify.app/](https://eager-shockley-a415b7.netlify.app/). Netlify is one of my favourite services that provides cloud hosting for static websites, offering continuous deployment, free SSL, serverless functions, and more... The final code adds some missing features to the website, for example it adds authors support, uses some external components omitted here for simplicity and enables SEO option to the project to add metadata to pages (see [SEO section in nuxt-18n documentation](https://nuxt-community.github.io/nuxt-i18n/seo.html)).

Another useful thing included in the final code is the sitemap, provided by the [Nuxt.js Sitemap module](https://github.com/nuxt-community/sitemap-module). Sitemap is easy to setup because it takes the `generate.routes` value by default, so dynamic routes will be automagically included. The configurations is really straightforward, just add `@nuxtjs/sitemap` at the end of `modules` array section of your `nuxt.config.js` file

```js
  {
    modules: [
      // ...
      '@nuxtjs/sitemap'
    ],
  }
```

and configure the `sitemap` section

```js
export default {
  // ...
  sitemap: {
    hostname: BASE_URL,
    gzip: true,
    i18n: DEFAULT_LOCALE
  }
  // ...
}
```

Checkout the [Nuxt Community organization on Github](https://github.com/nuxt-community) for more awesome modules and projects!

Happy coding! 💚

---

Cover image by [Marco Verch](https://www.flickr.com/photos/146269332@N03/46083654905) [(CC BY 2.0)](https://creativecommons.org/licenses/by/2.0/)