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.
pnpm add @nuxtjs/i18nexport 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 ownlocalesanddefaultLocale. When Nuxt I18n is installed, repeating either option undercontent.i18nmakes module setup reject the config. Put the locale list and default in thei18nblock; usecontent.i18nonly for content-specific policy.
Turn on localization for a collection
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.mdThe 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
<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.
<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.