Find the full project created in this guide on this repo : https://github.com/braanj/blog-nuxt-content
Installation & configuration of nuxt content
To get started, let’s first install the nuxt content module using the npx
command as mentioned in the official documentation.
npx nuxi@latest module add content
This command will handle the configuration for you by adding the module to modules property inside nuxt.config.ts
file.
export default { modules: ['@nuxt/content'] }
Installation & configuration of internationalisation (i18n)
Next, let’s install and configure the nuxt i18n
(internationalization) module to make the blog ready for an international audience.
The same way, install the nuxt i18n module using the npx
command:
npx nuxi@latest module add @nuxtjs/i18n@next
Add the configuration below inside nuxt.config.ts
file.
export default defineNuxtConfig({
// ...
modules: [
// ...
'@nuxtjs/i18n',
],
i18n: {
baseUrl: process.env.NUXT_PUBLIC_SITE_URL,
defaultLocale: 'en',
lazy: true,
locales: [
{
code: 'en',
language: 'en-US',
file: 'en.json',
name: 'English',
},
{
code: 'fr',
language: 'fr-FR',
file: 'fr.json',
name: 'French',
},
],
restructureDir: 'internationalization',
strategy: 'prefix',
detectBrowserLanguage: false,
},
})
For this particular blog, two languages will be configured: French and English (as default). Local static translation files will go inside the directory internationalization/locales
, using the language code as the file name.
For the strategy, prefix
will be used, to make sure the language code appears in the URLs.
Finally, the base url is set inside the .env
file under the variable NUXT_PUBLIC_SITE_URL
. The i18n module will complain about using https://localhost:3000
as a value. So make sure to set it to an actual domain name when in production.
Creating the first articles in markdown files
Let’s start the nuxt content machine. First, create the content folder. Inside it add two folders, each with the language code as a name. Then add a folder named blog
inside each of them.
It should look something like this:
Now, let’s add the markdown files.
Create two files:
first-article.md
inside the folder/en/blog
.premiere-article.md
inside the folder/fr/blog
.
Creating the article vue page
Now that content files were created, let’s fetch and display their content. But first, let’s create a vue page that will present the content.
Add another folder named blog
under the pages
folder (the pages folder isn’t created by default). Inside it, create a vue file named [slug].vue
.
Something like this:
Fetching the article content
Inside the single article file: [slug].vue
, add the following code:
<script setup lang="ts">
const path = computed(() => useRoute().path)
const { data: article } = await useAsyncData('single-article', () =>
queryContent(path.value).findOne(),
)
</script>
<template>
<pre>{{ article }}</pre>
</template>
In the localhost, navigate to the article (english version for example). The path is the same as the markdown file path: /en/blog/first-article
To test the French version, just navigate to its path: /fr/blog/premiere-article
Displaying the article content
Now, that the articles’ content is fetched correctly. Let’s display the content using the nuxt content renderer (ContentRenderer
) component.
<template>
<ContentRenderer v-if="article" :value="article">
<ContentRendererMarkdown :value="article" />
</ContentRenderer>
</template>
Add and fetch related articles
Add two or three more markdown articles. Then add the following code:
Inside the script
tag:
const { locale: currentLocale } = useI18n()
const { data: articles } = await useAsyncData('related-articles', async () => {
const data = await queryContent(currentLocale.value, 'blog')
.where({
_path: { $ne: path.value }, // Ignore current article
})
.limit(3)
.find()
return data
})
Inside the template
tag:
<div v-if="articles && articles.length">
<ul>
<li v-for="(article, key) in articles" :key="`article-${key}`">
<nuxt-link :to="article._path"> {{ article.title }}</nuxt-link>
</li>
</ul>
</div>
With this the user can navigate between related articles. But what about the languages? How can the user switch between them? Let’s set it up in the next step.
Adding language switcher
In the default
layout, add the following code:
<script setup lang="ts">
const { locale: currentLocale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
</script>
<template>
<div>
<template v-for="locale in locales" :key="locale.code">
<NuxtLink
v-if="locale.code !== currentLocale"
:to="switchLocalePath(locale.code)"
:title="locale.language"
>
<span>{{ locale.name }}</span>
</NuxtLink>
</template>
<main>
<NuxtPage />
</main>
</div>
</template>
When testing the language switcher on the article page, notice that the path itself doesn’t get translated. That is because the i18n
module doesn’t know the locale
version of dynamic URLs. Let’s correct that.
First, the local version of the same article should be tied together. Add a slug
property in the meta data section in the markdown files. Where the slug has two properties:
fr
property containing the french version slug- and
en
property containing the english version slug.
It should look something like this:
Replicate it for the other articles. Then add this code to [slug].vue
file:
const i18nParams = {
en: { slug: article.value.slug.en },
fr: { slug: article.value.slug.fr },
}
const setI18nParams = useSetI18nParams()
setI18nParams(i18nParams)
And that’s it, the user can now navigate between articles and also switch to his language. And all that is made possible using the magic of i18n along with nuxt content.
But that’s not all! In the upcoming parts we’ll create an index page for our blog.
Creating, Fetching, and displaying blog index page content
Create an index.md
file inside the blog folder for each language. Then add some content.
It should be something like this:
Now, let’s create an index page for the blog, and display the content. Then display all articles available.
Add this code:
<script setup lang="ts">
const path = computed(() => useRoute().path)
const { data: article, error } = await useAsyncData('blog', () =>
queryContent(path.value).findOne(),
)
if (error.value) {
throw createError({
status: 404,
message: 'Article not found!',
})
}
const { locale: currentLocale } = useI18n()
const { data: articles } = await useAsyncData('related-articles', async () => {
const data = await queryContent(currentLocale.value, 'blog')
.where({
_path: { $ne: path.value }, // Ignore current path
})
.find()
return data
})
</script>
<template>
<div>
<ContentRenderer v-if="article" :value="article">
<ContentRendererMarkdown :value="article" />
</ContentRenderer>
<div v-if="articles && articles.length">
<ul>
<li v-for="(article, key) in articles" :key="`article-${key}`">
<nuxt-link :to="article._path"> {{ article.title }}</nuxt-link>
</li>
</ul>
</div>
</div>
</template>
Let’s add some styling
Update the default vue file to include the styling:
<script setup lang="ts">
const { locale: currentLocale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const localePath = useLocalePath()
</script>
<template>
<div class="container">
<div class="flex justify-between">
<NuxtLink :to="localePath('index')">
<span>Blog Nuxt Content</span>
</NuxtLink>
<NuxtLink :to="localePath('blog')">
<span>Blog</span>
</NuxtLink>
<template v-for="locale in locales" :key="locale.code">
<NuxtLink
v-if="locale.code !== currentLocale"
:to="switchLocalePath(locale.code)"
:title="locale.language"
>
<span>{{ locale.name }}</span>
</NuxtLink>
</template>
</div>
<main>
<NuxtPage />
</main>
</div>
</template>
<style>
html,
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
}
.container {
max-width: 800px;
margin-left: auto;
margin-right: auto;
padding: 1px;
}
.flex {
display: flex;
}
.justify-between {
justify-content: space-between;
}
a {
text-decoration: none;
}
/* visited link */
a:visited {
color: green;
}
/* mouse over link */
a:hover {
color: #000;
}
/* selected link */
a:active {
color: blue;
}
</style>
Then add the nuxt content configuration inside the nuxt.config.ts
file to use the github-light
theme:
export default {
// ...
content: {
highlight: {
theme: 'github-light',
},
},
// ...
}
Adding meta data
Now that the blog is set up and the users can navigate seamlessly. It’s time to add meta data to the pages.
Adding meta title and description
The title has already been added to the markdown files. So add the description
property to all markdown articles.
It should be something like this (blog content as an example):
To display it, add the following code to the blog index.vue
file and to the article [slug].vue
file (replace data
by article
in the article vue page).
if (data.value) {
useHead({
title: data.value.title,
meta: [
{
name: 'description',
content: data.value.description,
},
],
})
}
Adding cover image
Let’s add a cover image to the pages (blog page and articles’ page). First add to each markdown file the following property in the head section:
cover:
src: https://picsum.photos/id/[REPLACEME]/500/300
alt: image description alternative text
Note: For images we’ll use the Lorem Picsum as an easy way to add images.
In the cover src replace the [REPLACEME]
with a number starting from 0. Check the official Lorem Picsum website for more information on how to use it.
Now that the cover images were added. Let’s use it in the front. For that, we’ll introduce the first custom components: ArticlesList
.
<script setup lang="ts">
import type { ParsedContent } from '@nuxt/content'
defineProps<{
articles?: ParsedContent[]
}>()
</script>
<template>
<ul class="articles-list">
<li
class="article"
v-for="(article, key) in articles"
:key="`article-${key}`"
>
<nuxt-link :to="article._path">
<img
class="article-img"
:src="article.cover.src"
:alt="article.cover.alt"
/>
</nuxt-link>
<div class="article-text">
<nuxt-link :to="article._path"> {{ article.title }}</nuxt-link>
<small>{{ article.description }}</small>
</div>
</li>
</ul>
</template>
<style scoped>
.articles-list {
padding-left: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 25px;
}
.article {
display: grid;
gap: 10px;
}
.article-img {
width: 100%;
height: auto;
border-radius: 5px;
}
.article-text {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>
Then replace the articles list inside the blog and article vue page with this component. And display the cover image also in the blog and article page.
Inside article page, replace the template
code:
<template>
<div v-if="article">
<h1>
{{ article.title }}
</h1>
<p>{{ article.description }}</p>
<div class="flex">
<img
class="cover-image"
:src="article.cover.src"
:alt="article.cover.alt"
/>
</div>
<ContentRenderer :value="article">
<ContentRendererMarkdown :value="article" />
</ContentRenderer>
<div v-if="articles && articles.length">
<h2>Articles</h2>
<LazyArticlesList :articles="articles" />
</div>
</div>
</template>
And inside blog page, replace the template
code:
<template>
<div v-if="data">
<div class="flex relative">
<img class="cover-image" :src="data.cover.src" :alt="data.cover.alt" />
<h1 class="absolute centered">
{{ data.title }}
</h1>
</div>
<ContentRenderer :value="data">
<ContentRendererMarkdown :value="data" />
</ContentRenderer>
<LazyArticlesList v-if="articles && articles.length" :articles="articles" />
</div>
</template>
Conclusion
In conclusion, creating an international blog with Nuxt Content and i18n is a seamless process. By following these steps, localized content, dynamic language switcher, and robust navigation are set up.
Stay tuned for the next part, where we’ll dive into SEO, analytics, and deployment to take your blog to the next level!
Don't forget to fork and use the public repository : https://github.com/braanj/blog-nuxt-content
Thanks for reading!