How to build a JAMStack multi language blog with Nuxt.js

By Andrea Stagi

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 | ============ ============

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.

Setup Nuxt.js project

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

npx create-nuxt-app multilangblog

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

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

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()
}

and add it to plugins in nuxt.config.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 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>

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>

Add i18n

To setup i18n install Nuxt i18n module

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

{ modules: ['nuxt-i18n']
}

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ù' } } } } // ...
}

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>

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

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] }
},
// ....

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>

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] }
},
//...

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

Static generation

To generate a static version of the blog with Nuxt.js launch

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

ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
✨ Done in 43.49s.

as you can see dynamic routes are not generated, because Nuxt.js doesn't know how to generate them. To add dynamic routes support 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 { // ... 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

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 yarn generate again

ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
✔ Generated /blog/hello-world
✔ Generated /it/blog/ciao-mondo
✨ Done in 33.82s.

Now Nuxt.js is able to generate dynamic routes 🎉

You can test your static site installing http-server and launching

A problem you can notice after static generation is that asyncData calls are still made during client-side navigation, which means that an external API server should run while your static website is online (if Strapi server is down you'll get "Failed to fetch" message). This is a known problem and Nuxt.js team is working to support full static generation for nuxt generate in the next version, for now you can use nuxt-payload-extractor plugin. Install it with

yarn add nuxt-payload-extractor

and add the module in nuxt.config.js

{ modules: [ // ... 'nuxt-payload-extractor' ]
}

the final code of getSinglePostFromContext will be

const getSinglePostFromContext = async ({ $axios, $payloadURL, app, route, store, payload }) => { if (payload) { return payload } let postsData = null if (process.static && process.client && $payloadURL) { postsData = await $axios.$get($payloadURL(route)) return postsData.post } postsData = await app.$blogClient.getSinglePost( route.params.slug, store.$i18n.locale ) return postsData.data.transPosts[0]
}

You need to add nuxt-payload-extractor to HomePage too

async asyncData ({ $axios, $payloadURL, app, route, store }) { if (process.static && process.client && $payloadURL) { return await $axios.$get($payloadURL(route)) } const postsData = await app.$blogClient.getAllPostsHead( store.$i18n.locale ) return { posts: postsData.data.transPosts }
}

Your fully static generated blog is ready 🎉

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' } }, // ...
}

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

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

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
}

Run yarn generate again

ℹ Generating pages
✔ Generated /blog/hello-world
✔ Generated /it/articoli/ciao-mondo
✔ Generated /es/artículos/hola-mundo
✔ Generated /es/
✔ Generated /it/
✔ Generated /
✨ Done in 33.82s.

and your fully 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' ], }

and configure the sitemap section

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

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

Happy coding! 💚

Cover image by Marco Verch (CC BY 2.0)