# Nuxt

Add Comvi i18n to a Nuxt 3 or 4 app with the @comvi/nuxt module: auto-imported composables, locale-prefixed routes, one i18n instance per request and hydration from the server.

Canonical: https://comvi.io/docs/i18n/nuxt

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](https://stackblitz.com/github/comvi-io/comvi-js/tree/main/examples/nuxt) to see server rendering, the locale in the URL (`/de`), plurals and `<T>` with a link inside the text in a running app.

## Install
**npm**

```bash
npm install @comvi/nuxt
```

**pnpm**

```bash
pnpm add @comvi/nuxt
```

**yarn**

```bash
yarn add @comvi/nuxt
```

**bun**

```bash
bun add @comvi/nuxt
```

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

## Setup

`nuxt.config.ts`

```ts

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`

```vue
<template>

</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.

## Use in components

`app/pages/index.vue`

```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()`.

### Markup inside a translation

`app/components/LegalNotice.vue`

```vue
<template>

    <template #link="{ children }">
      <NuxtLinkLocale to="/terms">{{ children }}</NuxtLinkLocale>
    </template>

</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`.

## Load from Comvi

`comvi.i18n.ts`

```ts

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](/docs/i18n/connect-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](/docs/platform/cdn#url-format).
If your keys use ICU (`{count, plural, …}`), add `compiler: icuCompiler` as on
[Pluralize with ICU](/docs/i18n/pluralization). 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.

## First paint

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.

## Server-side rendering

`comvi.i18n.ts`

```ts

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](/docs/i18n/ssr#nuxt).

## Locale routing

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

```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](/docs/i18n/ssr#how-the-accept-language-header-is-matched).

## How it works

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](/docs/i18n/vue#how-it-works) has
that model in full. A missing key returns the key and emits `missingKey`, on the server and in the
browser alike.

## Errors

| 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](/docs/i18n/errors#comvinuxt-comvikey-is-not-an-option) |
| `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](/docs/i18n/troubleshooting).

## Read next

- [Connect to Comvi](/docs/i18n/connect-comvi): the CDN URL, and a wording change that ships without a redeploy.
- [Render on the server](/docs/i18n/ssr#nuxt): the server utilities and the request lifecycle.
- [Nuxt API reference](/docs/i18n/api/nuxt): every module option, `defineComviI18n`, the setup types, the composables.
