How to build a Jamstack multi-language blog with Nuxt.js
Andrea Stagi
Posted on May 21, 2020
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. 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
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
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, showingslug
andtitle
. -
getAllPosts
: fetches all the posts in a specific language, showingslug
,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
yarn add nuxt-i18n
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] }
},
//...
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
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
(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.
👉🏻 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
}
}
//...
}
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 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.
Now Nuxt.js is able to generate dynamic routes 🎉
You can test your static site installing using
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
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
}
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.
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'
],
}
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)
Posted on May 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.