Alvaro Saburido
Posted on July 2, 2020
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
It will ask you some config questions, when you prompt with Nuxt.js modules option, you can select the content module directly:
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
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
Then add the module to nuxt.config.js
:
{
modules: [
[
'nuxt-i18n',
{ /* module options */ }
]
],
// Or with global options
i18n: {}
}
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,
}
]
],
}
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'),
},
},
},
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>
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
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
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.
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>
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();
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.
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>
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>
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>
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>
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:
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>
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:
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}`, ''),
})),
};
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>
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>
<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 */
}
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>
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');
},
};
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,
},
],
};
},
};
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 theFrontmatter
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');
},
},
};
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
---
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 || []
},
}
}
And voilá. Now you have a nice multi-language blog.
If you enjoy the article, you have questions or any feedback let's talk in the comments 💬.
Happy coding!.
Posted on July 2, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.