Skip to content

i18next

@verific/i18next uses a caller-owned i18next 26 instance. Install that same instance through i18next-vue when the rest of the application also uses i18next.

Install and configure the application

bash
pnpm add @verific/core @verific/i18next i18next i18next-vue vue zod
ts
import type { MissingMessageMode } from '@verific/i18n'
import type { App } from 'vue'
import { createVerific } from '@verific/core'
import { i18nextMessages } from '@verific/i18next'
import { createInstance } from 'i18next'
import I18NextVue from 'i18next-vue'

export const i18n = createInstance()

export async function createValidationI18next(missing?: MissingMessageMode) {
  if (!i18n.isInitialized) {
    await i18n.init({
      fallbackLng: 'en',
      lng: 'en',
      resources: {
        en: { translation: { errors: { invalidEmail: 'Enter a valid email address' } } },
        es: { translation: { errors: { invalidEmail: 'Introduce una dirección de correo válida' } } },
      },
    })
  }

  const messages = i18nextMessages(i18n, {
    fallbackPrefix: 'errors',
    missing,
  })

  return { i18n, messages }
}

// #region strict-missing
export function createStrictValidationMessages() {
  return i18nextMessages(i18n, {
    fallbackPrefix: 'errors',
    missing: 'throw',
  })
}
// #endregion strict-missing

export async function installValidation(app: App) {
  const { i18n, messages } = await createValidationI18next()
  app.use(I18NextVue, { i18next: i18n })
  app.use(createVerific({ messages }))

  return messages.dispose
}

Await installValidation(app) before mounting and call its returned disposer before unmounting. This displayed source is compiled by the documentation test suite against i18next 26, i18next-vue and the exported adapter.

The instance supplied to i18next-vue and i18nextMessages() is deliberately the same. The adapter listens for language, catalogue-load and resource-store changes. dispose() removes only its own listeners and is safe to call repeatedly.

Use it in a form

vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { reactive } from 'vue'
import { z } from 'zod'
import { i18n } from './i18next-setup'

const form = reactive({ email: '' })
const schema = z.object({ email: z.email() })
const { errorsFor, hasError, validate, validateFor } = useValidation(schema, form, {
  messagePrefix: 'forms.signup',
})

async function submit() {
  const result = await validate()
  if (result.success) {
    // Submit application-owned state.
  }
}
</script>

<template>
  <form novalidate @submit.prevent="submit">
    <label for="email">Email</label>
    <input
      id="email"
      v-model="form.email"
      type="email"
      :aria-invalid="hasError('email')"
      aria-describedby="email-errors"
      @blur="validateFor('email')"
    >
    <div id="email-errors" aria-live="polite" aria-atomic="true">
      <p v-for="(error, index) in errorsFor('email')" :key="`${index}:${error}`">
        {{ error }}
      </p>
    </div>
    <button type="submit">
      Continue
    </button>
  </form>

  <button type="button" @click="i18n.changeLanguage(i18n.language === 'en' ? 'es' : 'en')">
    Change message language
  </button>
</template>

The form imports the exact caller-owned instance installed above. This focused locale-switch flow is also compiled and exercised:

ts
import { i18n } from './i18next-setup'

export async function changeMessageLanguage() {
  const nextLocale = i18n.language === 'en' ? 'es' : 'en'
  await i18n.changeLanguage(nextLocale)
}

The language event invalidates derived errors; it does not rerun the schema. Configured namespace fallback remains i18next-owned within one selected locale, while Verific applies the shared key-first order across catalogue candidates.

Try it in the browser

  1. Select Validate with i18next once.
  2. Change Message language to Español.
  3. The committed message changes while Validation runs remains 1.
  4. Select Demonstrate missing-key fallback. The schema fallback and exact missing key and locale appear, still without another schema run. Select it again to restore the translated message.

Validation runs: 0

Validate once, then change the locale.

View the source used by this example
vue
<script setup lang="ts">
import type { CatalogueMissingMessageDiagnostic } from '@verific/i18n'
import { useValidation } from '@verific/core'
import { i18nextMessages } from '@verific/i18next'
import { createInstance } from 'i18next'
import { nextTick, onUnmounted, ref } from 'vue'
import { z } from 'zod'

const i18n = createInstance()
void i18n.init({
  fallbackLng: false,
  initAsync: false,
  lng: 'en',
  resources: {
    en: { translation: { errors: { invalidEmail: 'Enter a valid email address' } } },
    es: { translation: { errors: { invalidEmail: 'Introduce una dirección de correo válida' } } },
  },
})

const locale = ref<'en' | 'es'>('en')
const email = ref('not-an-email')
const runCount = ref(0)
const demonstrateMissing = ref(false)
const missingSequence = ref(0)
const missingDiagnostic = ref('')
const schema = z.object({
  email: z.email('Schema fallback: enter a valid email address'),
}).superRefine(() => {
  runCount.value += 1
})
const messages = i18nextMessages(i18n, {
  fallbackPrefix: 'errors',
  key: ({ defaultKeys }) => demonstrateMissing.value
    ? [`demo.missing.${missingSequence.value}`]
    : defaultKeys,
  missing: reportMissing,
})
const outcome = ref('Validate once, then change the locale.')
const { errorsFor, hasError, isValidating, validate } = useValidation(
  schema,
  { email },
  { messages },
)

onUnmounted(messages.dispose)

async function onSubmit() {
  const result = await validate()
  outcome.value = result.success
    ? 'The email address is valid.'
    : 'The committed error is translated when the locale changes.'

  if (!result.success) {
    await nextTick()
    document.getElementById('i18next-email')?.focus()
  }
}

async function changeLocale() {
  await i18n.changeLanguage(locale.value)
}

function reportMissing(diagnostic: CatalogueMissingMessageDiagnostic) {
  const attempt = diagnostic.attempts[0]
  const key = attempt?.keys[0] ?? 'unknown key'
  const attemptedLocale = attempt?.locale ?? 'unknown locale'
  missingDiagnostic.value = `Missing catalogue message. Add "${key}" for locale "${attemptedLocale}".`
}

function toggleMissingDemonstration() {
  missingDiagnostic.value = ''
  demonstrateMissing.value = !demonstrateMissing.value
  if (demonstrateMissing.value) {
    missingSequence.value += 1
  }
}
</script>

<template>
  <div class="verific-example">
    <form novalidate @submit.prevent="onSubmit">
      <div class="verific-example__toolbar">
        <div class="verific-example__field">
          <label for="i18next-locale">Message language</label>
          <select id="i18next-locale" v-model="locale" data-validation-skip @change="changeLocale">
            <option value="en">
              English
            </option>
            <option value="es">
              Español
            </option>
          </select>
        </div>
        <p class="verific-example__counter" aria-live="polite">
          Validation runs: <strong>{{ runCount }}</strong>
        </p>
      </div>

      <div class="verific-example__field">
        <label for="i18next-email">Email address</label>
        <input
          id="i18next-email"
          v-model="email"
          type="email"
          autocomplete="email"
          :aria-invalid="hasError('email')"
          aria-describedby="i18next-email-errors"
        >
        <ul
          id="i18next-email-errors"
          class="verific-example__errors"
          aria-live="polite"
          aria-atomic="true"
          :lang="locale"
        >
          <li v-for="(error, index) in errorsFor('email')" :key="`${index}:${error}`">
            {{ error }}
          </li>
        </ul>
      </div>

      <div class="verific-example__actions">
        <button type="submit" :disabled="isValidating">
          {{ isValidating ? 'Validating…' : 'Validate with i18next' }}
        </button>
        <button
          type="button"
          :aria-pressed="demonstrateMissing"
          aria-controls="i18next-missing-diagnostic"
          @click="toggleMissingDemonstration"
        >
          Demonstrate missing-key fallback
        </button>
      </div>

      <p
        v-show="missingDiagnostic"
        id="i18next-missing-diagnostic"
        class="verific-example__outcome"
        role="status"
        aria-live="polite"
      >
        {{ missingDiagnostic }}
      </p>

      <p class="verific-example__outcome" role="status" aria-live="polite">
        {{ outcome }}
      </p>
    </form>
  </div>
</template>

Missing keys and SSR

ts
export function createStrictValidationMessages() {
  return i18nextMessages(i18n, {
    fallbackPrefix: 'errors',
    missing: 'throw',
  })
}

After validating in a test, read errorsFor('email') to exercise this strict adapter. The shown factory is compiled and its throw path is tested.

For SSR, create and initialise the i18next instance and adapter inside each request. Dispose the adapter when that request or application finishes; never export a mutable server singleton. See the request-safe Nuxt plugin.

See the shared missing-message policies and @verific/i18next package README for the complete adapter surface.