Skip to main content

Build a multilingual site

Combine Nuxt I18n with Ginko for localized content, fallback, and a language switcher.

Nuxt I18n handles locales and URLs. Ginko handles the content variants. Install both and wire them once.

terminal
pnpm add @nuxtjs/i18n
nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@lupinum/ginko-content',
    '@nuxtjs/i18n'
  ],
  i18n: {
    locales: [
      { code: 'en', language: 'en-US' },
      { code: 'de', language: 'de-DE' }
    ],
    defaultLocale: 'en',
    strategy: 'prefix_except_default'
  },
  content: {
    i18n: {
      fallback: {
        de: ['en']
      }
    }
  }
})

The content.i18n block holds only fallback and translated-slug policy.

Let Nuxt I18n own locales and defaultLocale. When Nuxt I18n is installed, repeating either option under content.i18n makes module setup reject the config. Put the locale list and default in the i18n block; use content.i18n only for content-specific policy.

Turn on localization for a collection

content.config.ts
import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'

export const docs = defineCollection({
  type: 'page',
  source: 'docs/**/*.md',
  route: '/docs',
  i18n: true
})

export default defineContentConfig({ collections: { docs } })

One collection covers every locale. Write each variant at the same path under its language folder:

content/
  en/docs/
    index.md
    installation.md
  de/docs/
    index.md
    installation.md

The file path stays the same in both languages. That shared slug is how Ginko pairs the English and German versions of a page.

Render a page with fallback and a switcher

app/pages/docs/[...slug].vue
<script setup lang="ts">
import { createError } from '#imports'
import { docs } from '~~/content.config'

definePageMeta({ key: route => route.path })

const { page } = await useContentPage(docs, { fallback: true })

if (!page.value) {
  throw createError({ statusCode: 404, statusMessage: 'Document not found', fatal: true })
}
</script>

<template>
  <article v-if="page">
    <nav aria-label="Languages">
      <NuxtLink
        v-for="alternate in page.route.alternates"
        :key="alternate.locale"
        :to="alternate.path"
      >
        {{ alternate.locale }}
      </NuxtLink>
    </nav>

    <p v-if="page.resolution.usedFallback">
      This page is not available in the requested language.
    </p>

    <ContentRenderer :value="page" />
  </article>
</template>

With fallback: true, a request for a missing German page serves the English one. Loop over page.route.alternates to build the language switcher; each entry carries a locale and its matching path.

When page.resolution.usedFallback is true, the reader is seeing content from the fallback chain, so tell them.

Keep lists and search in one locale

List queries can infer the active app locale. Search does not: pass Nuxt I18n's reactive locale so results update when the reader switches languages.

app/pages/search.vue
<script setup lang="ts">
import { useContentSearch } from '@lupinum/ginko-content/client'
import { useI18n } from 'vue-i18n'

const { locale } = useI18n()
const { query, results } = await useContentSearch({
  locale: () => locale.value
})
</script>

Without locale, useContentSearch searches every locale in the index. In code with no active app locale, such as a server job, pass the intended locale explicitly.

Shared slugs keep the content path aligned across locales. Nuxt I18n still prefixes the German public URL, such as /de/docs/installation. Turn on translatedSlugs only when each locale needs different path segments.