Anjjar logo
  • Homepage
  • /
  • Blog
  • /
  • Nuxt Content: creating an international blog using i18n

Nuxt Content: creating an international blog using i18n

Nuxt.jsPublished on , updated on

Nuxt content made the blogging easy. This article will guide you to create an international blog using i18n module along with nuxt content.

creating an international blog using i18n article cover image

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:

Nuxt folder structure

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:

Nuxt folder structure

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

Article markdown content

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:

Blog page markdown content

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

Description property inside blog page markdown content

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!