Skip to content
Comvi

Nuxt

View .md

With a Nuxt 3 or 4 app, @comvi/nuxt is one module entry: useI18n() and <T> arrive auto-imported, routes get a locale prefix, and the server builds one i18n instance per request and ships its catalog to the client in the payload.

Open the Nuxt example in StackBlitz to see server rendering, the locale in the URL (/de), plurals and <T> with a link inside the text in a running app.

Terminal window
npm install @comvi/nuxt

@comvi/nuxt peer-depends on nuxt (^3 or ^4) and brings @comvi/core and @comvi/vue.

nuxt.config.ts
import { defineNuxtConfig } from 'nuxt/config';
export default defineNuxtConfig({
modules: ['@comvi/nuxt'],
comvi: {
defaultLocale: 'en',
locales: ['en', 'uk'],
messages: {
en: { greeting: 'Hello, {name}!', legal: { tos: 'Read our <link>Terms of Service</link>' } },
uk: { greeting: 'Привіт, {name}!', legal: { tos: 'Прочитайте наші <link>Умови використання</link>' } },
},
},
});

comvi.messages is the inline catalog, nested or flat. It travels through runtime config, so it must stay JSON-serializable. locales and defaultLocale drive the route prefixes and the locales ref useI18n() returns; a locale can also be an object, { code, name?, iso?, dir? }.

app/app.vue
<template>
<NuxtPage />
</template>

Paths follow Nuxt 4’s app/ directory; on Nuxt 3 drop that prefix.

The module writes lang and dir on <html> from the active locale: on the server before the first byte, and again on every locale switch in the browser. lang is the locale object’s iso when you set one, else its code; dir is the locale object’s dir, else the direction of that language. Set comvi.head: false to own both attributes yourself. useLocaleHead() is the call for the rest of the head, canonical, hreflang and og:locale, and calling it as well is fine: both write the same values.

app/pages/index.vue
<script setup lang="ts">
const { t, locale } = useI18n();
</script>
<template>
<h1>{{ t('greeting', { name: 'Alice' }) }}</h1>
<NuxtLinkLocale to="/" :locale="locale === 'en' ? 'uk' : 'en'">Switch</NuxtLinkLocale>
</template>

useI18n() is auto-imported and returns the @comvi/vue composable plus locales and defaultLocale from runtime config. setLocale() loads the new catalog, applies the locale in place and writes the cookie; it never navigates, so on its own it is a complete switcher only under localePrefix: 'never'. When the URL carries the locale, switch by navigating: <NuxtLinkLocale :locale> as in the example, which also records the choice when the link goes to another page, or a link to useSwitchLocalePath()(code) for the same page in another locale. Awaiting setLocale(code) and then navigateTo(switchLocalePath(code)) is the same switch written out. $t() works in any template without calling useI18n().

app/components/LegalNotice.vue
<template>
<T i18nKey="legal.tos">
<template #link="{ children }">
<NuxtLinkLocale to="/terms">{{ children }}</NuxtLinkLocale>
</template>
</T>
</template>

<T> is auto-imported. Each tag a translator wrote becomes a named slot, or an entry in the components prop, and renders real elements. Plain t() does not parse tags. Never render a translation with v-html.

comvi.i18n.ts
import { createI18n, defineComviI18n } from '@comvi/nuxt/i18n';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
export default defineComviI18n((options) =>
createI18n(options).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' })),
);

Install @comvi/plugin-fetch-loader and add this file next to nuxt.config.ts. The module picks up ./comvi.i18n.* by convention and logs the path it found; comvi.i18nModule points at a file elsewhere. defineComviI18n() types the factory and hands it back unchanged. The factory receives the resolved comvi options and runs once for the client and once per server request, so it returns a new instance from createI18n on every call; in development the module throws when it gets the same instance twice. See Connect to Comvi.

The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content Deploy is enabled and saved; pass it as cdnUrl. The URL format is on CDN Deployment.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

On Nuxt, comvi.icu: true in nuxt.config.ts adds compiler: icuCompiler to the options the factory receives.

The server loads the catalog, renders, and writes the instance’s cache into the Nuxt payload. The client plugin adds that payload back before init(), so there is no gate to write and no flash of raw keys.

comvi.i18n.ts
import { createI18n, defineComviI18n, loader } from '@comvi/nuxt/i18n';
export default defineComviI18n((options) =>
createI18n(options).use(loader({ de: () => import('./locales/de.json') })),
);

On every request the module calls the factory, runs comvi.setup.ts against the new instance, awaits init(), lets the route middleware detect the locale from the URL path, the query parameter, the cookie or the Accept-Language header, and after the render serializes the loaded cache into the payload, so the client hydrates with no second fetch. Server-side loading is the loader capability: compose .use(loader(map)) or .use(cdnLoader()) in comvi.i18n.ts. Without that file the module builds core’s base instance, with no loader and no ICU compiler, and comvi.messages is the only catalog it ever has. In a Nitro route the server auto-imports are useTranslation(event), loadMessages(event, locale), getRequestI18n(event, locale) and getServerRuntimeConfig(event); the app-side composables do not exist there. useTranslation(event) resolves the locale as the route middleware does, from a locale prefix on the path and then localeDetection.order, except that a prefixless path is not read as the default locale, since an API route carries no prefix to omit. The rendered <html> carries lang and dir for the request’s locale, so a right-to-left locale arrives right-to-left on first paint. The full pattern is on Render on the server.

localePrefix Default locale Other locale
as-needed /about /uk/about
always /en/about /uk/about
never /about /about, locale from cookie or header
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@comvi/nuxt'],
comvi: {
defaultLocale: 'en',
locales: ['en', 'uk'],
localeCookie: { name: 'locale', sameSite: 'strict' },
localeDetection: { order: ['query', 'cookie', 'accept-language'], queryParam: 'lang' },
},
});

The route middleware detects the locale, persists it in the locale cookie (i18n_locale unless localeCookie names another), calls setLocale() so the catalog is in place before the page renders, and redirects when the URL and the rendered locale disagree. localeCookie is false, a cookie name, or { name, maxAge, secure, sameSite, domain }; localeDetection is false or { order, queryParam, headerName, resolveAcceptLanguage }, and query reads nothing until queryParam names a parameter. The same two options, with the same values and defaults except the cookie name, configure @comvi/next’s middleware. <NuxtLinkLocale> and useLocalePath() leave absolute URLs, mailto: and tel: links, and query-only or fragment-only targets as written. Routing and Accept-Language matching share one implementation with @comvi/next; the rules are on Language detection on the server.

The module registers a Nuxt plugin that runs before the others. On each run it reads the comvi runtime config, calls the factory from comvi.i18n.ts (or the generated default, which is core’s base instance) with those options, and gets back a @comvi/vue wrapper whose ssrLocale is the current locale state, defaultLocale at first. It then runs comvi.setup.ts, awaits init(), and only afterwards calls nuxtApp.vueApp.use(i18n) and provides $i18n, so no component renders against an instance that is still loading. With head on, the same run registers one head entry that computes htmlAttrs.lang and htmlAttrs.dir from the locale state, so the server HTML has both; in the browser a watcher writes the new values in the same flush as the translated text, and touches an attribute only while its current value is the one it wrote last, so your own useHead() entry for <html> wins.

In development the module’s generated code remembers every instance the factory returned and throws when it sees one a second time. A factory that hands back a module-level singleton would share one locale and one catalog across every request. Production builds carry no guard.

On the server that instance belongs to one request. It is parked on the request event, and once the app has rendered the plugin serializes the translation cache, keyed locale:namespace, into the Nuxt payload. After the response is written, every instance the request built is destroyed, which is why a server utility called after the response throws rather than translating from a cleared cache.

The route middleware runs on every navigation and decides the locale in a fixed order: the path prefix, then localeDetection.order, by default the query parameter named in queryParam when you set one, then the cookie, then Accept-Language on the server only, then defaultLocale. Under as-needed a prefixless path other than / is the default locale’s URL, decided after a leading query source and before the rest; / is too when the navigation comes from the same page under a locale prefix (/de to /, what useSwitchLocalePath() returns for the default locale), and on every other visit / is decided by localeDetection.order and redirects to that locale’s prefixed root when it is not the default. When the result differs from the instance’s locale it calls $i18n.setLocale(), which loads that locale’s catalog before applying it, writes the locale state and the cookie, and redirects when the prefix mode says the URL should look different. init() loaded defaultLocale and every locale in the fallbackLocale chain for the active namespaces; this is the step that loads the visitor’s locale. A namespace a page activates during the render loads its fallback chain the same way, as long as the page awaits the activation. With ssr: false there is no server step and no payload: the browser makes the same requests itself on first paint, the active locale and its fallback chain for the active namespaces.

In the browser the plugin reads the payload and calls addMessages() before init(), so the default-namespace load is a cache hit and nothing is fetched on first paint. From then on localeChanged writes the locale state and the cookie, and a watcher on the locale state mirrors the middleware’s decisions back onto the instance, so a route change and a setLocale() call end in the same place.

useI18n() is the @comvi/vue composable with two refs added, so the reactivity model is Vue’s: t() tracks the locale ref and the cache revision, and a template that calls it re-renders on a switch or a namespace load and at no other time. The Vue page has that model in full. A missing key returns the key and emits missingKey, on the server and in the browser alike.

what happened message
useI18n() in an app without the module in modules [@comvi/nuxt] i18n not configured. Add "@comvi/nuxt" to `modules` in nuxt.config.ts.
a comvi key from an older release at build time, naming the key to write, as in [@comvi/nuxt] `comvi.detectBrowserLanguage` is not an option: use `localeCookie` and `localeDetection`.; the full list is on Error codes
comvi.i18nModule points at a file that does not exist [@comvi/nuxt] Failed to resolve comvi i18nModule path: "./comvi.i18n".
the comvi.i18n.ts factory returned the same instance twice (development only) [@comvi/nuxt] comvi i18nModule returned an instance it already returned. Every call builds a separate instance (the client, and each server request), so return a new instance from createI18n on every call.

More messages and their fixes are on Troubleshooting.