DEV Community

loading...
Cover image for How to build a Jamstack multi-language blog with Nuxt.js

How to build a Jamstack multi-language blog with Nuxt.js

astagi profile image Andrea Stagi ・Updated on ・9 min read

Jamstack (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, a powerful Vue framework that supports SPA, SSR and statically generated renderings in conjunction with Strapi Headless CMS to store data and expose them to generate a static blog. To setup Strapi locally you can follow this guide otherwise you can use a read only instance running on our server at https://strapi.lotrek.net/.

👉🏻 You can find the complete code of this tutorial in this repository.

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

       ____________                        ____________
      |    POST    |                      | TRANS_POST |
       ============                        ============
      | published  |                      | language   |
      | created_at | <--(1)-------(N)-->> | title      |
      |            |                      | content    |
      |            |                      | slug       |
       ============                        ============
Enter fullscreen mode Exit fullscreen mode

You can play with it using GraphQL playground 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

Setup Nuxt.js project

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

npx create-nuxt-app multilangblog
Enter fullscreen mode Exit fullscreen mode

Remember to select axios option (you'll need it later) and add a UI framework such as Buefy.

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 for a more structured and newer plugin)

yarn add apollo-fetch
Enter fullscreen mode Exit fullscreen mode

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.
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
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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

import BlogClient from '~/services'

export default ({ app }, inject) => {
  app.$blogClient = new BlogClient()
}
Enter fullscreen mode Exit fullscreen mode

and add it to plugins in nuxt.config.js

export default {
  // ...
  plugins: ['~/plugins/ctx-inject.js']
}
Enter fullscreen mode Exit fullscreen mode

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 and render title and link for each post. asyncData receives the context as the first argument and your BlogClient instance is accessible at context.app.$blogClient

<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>
Enter fullscreen mode Exit fullscreen mode

Add /blog/<postslug> route creating the component BlogPost (pages/blog/_slug.vue). Install Vue Markdown component to render the article correctly (yarn add vue-markdown)

<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>
Enter fullscreen mode Exit fullscreen mode

Add i18n

To setup i18n install Nuxt i18n module

yarn add nuxt-i18n
Enter fullscreen mode Exit fullscreen mode

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

{
  modules: ['nuxt-i18n']
}
Enter fullscreen mode Exit fullscreen mode

and setup i18n

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ù'
        }
      }
    }
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

//...
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] }
},
//...
Enter fullscreen mode Exit fullscreen mode

👉🏻 More on language switcher

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

yarn dev
Enter fullscreen mode Exit fullscreen mode

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 from the official Nuxt.js blog and follow the changes in the repository.

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

nuxt build && nuxt export
Enter fullscreen mode Exit fullscreen mode

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

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

ℹ 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.
Enter fullscreen mode Exit fullscreen mode

👉🏻 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.

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
    }
  }
  //...
}
Enter fullscreen mode Exit fullscreen mode

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

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 }
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Run nuxt build && nuxt export again

ℹ 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.
Enter fullscreen mode Exit fullscreen mode

Now Nuxt.js is able to generate dynamic routes 🎉

You can test your static site installing using

nuxt serve
Enter fullscreen mode Exit fullscreen mode

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

i18n {
  // ...
  parsePages: false,
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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

export default {
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

and import it in nuxt.config.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,
      // ...
    }
  },
  // ...
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

Build and export everything again and you'll get

ℹ 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.
Enter fullscreen mode Exit fullscreen mode

Your full static generated blog with custom paths is ready 🎉

You can do more

In this repository you can see the complete code of this tutorial, the result is deployed on Netlify CDN at 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).

Another useful thing included in the final code is the sitemap, provided by the Nuxt.js 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

  {
    modules: [
      // ...
      '@nuxtjs/sitemap'
    ],
  }
Enter fullscreen mode Exit fullscreen mode

and configure the sitemap section

export default {
  // ...
  sitemap: {
    hostname: BASE_URL,
    gzip: true,
    i18n: DEFAULT_LOCALE
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Checkout the Nuxt Community organization on Github for more awesome modules and projects!

Happy coding! 💚


Cover image by Marco Verch (CC BY 2.0)

Discussion (10)

pic
Editor guide
Collapse
xeroxi91 profile image
Matteo Moschitta

Thanks Andrea! Really interesting article.

I'm tryng to better understand how to setup the backend structure on Strapi.
Can you provide a repo or the access to the read only instance running on your server at strapi.lotrek.net/ please?

Collapse
astagi profile image
Andrea Stagi Author • Edited

Thank you :) Sure, I've just created the repo github.com/astagi/nuxti18n-strapi-...

My setup is really naive, you may have duplicated translations in the same language for the same post. Unfortunately I don't know how to define two fields "unique" as couple in Strapi.. 😅 If you plan to use GraphQL consider suffixing your fields with language code to support i18n in Strapi, e.g. title_it, title_en, title_fr, content_it, content_en, content_fr and so on... My solution behaves friendly in the admin UI, though.

Collapse
xeroxi91 profile image
Matteo Moschitta

Thanks Andrea!

Collapse
bzkjyf profile image
bzkjyf

hi Andrea:
how to use @nuxtjs/apollo fetch fields( title_en, title_fr) with lang in the frontend?

Collapse
zvekov profile image
Yury Z.

Great!

you used old package apollo-fetch
you can say how used with @nuxtjs/apollo ?

Thanks!!!

Collapse
astagi profile image
Andrea Stagi Author

Hi Yury,

you're right, I used that old package to keep the service part as simple as possible to provide a service both in nuxt app and nuxt config for generated routes :) I haven't tried @nuxtjs/apollo yet but it seems a great package, you just need to provide a configuration and wrap your queries in .gql files, then you can fetch data in your components. The only doubt I have is if you can use a client instance in nuxt.config.js for generating routes, looks like there's a workaround in this issue github.com/nuxt-community/apollo-m...

Collapse
viviansolide profile image
Vivian Sarazin

Priceless ressource.
Thank you so much!

Collapse
a1tem profile image
Artem Petrusenko

Thank you Andrea! Really helpful!

Collapse
jainsuneet profile image
Suneet Jain

Hi Andrea,

Can you please share the netlify.toml file for this deployment. I am always getting error from netlify that /dist folder doesn't exist

Collapse
f3ltron profile image
florent giraud

Hello Thank's for the article ! You never had the issue that i18n/setRouteParams never works?

For me its working in one case but not in another one but both code are exactly the same