Skip to content

Translations ​

A definition keeps texts in its source language. Translate it with your app's keys or with an overlay file per language.

bash
npm install @formhaus/core @formhaus/react   # or @formhaus/vue

Messages and labels ​

messages and labels replace the built-in English texts. {min}, {max}, {current} and {total} are filled in.

json
{
  "id": "feedback",
  "lang": "ru",
  "title": "Обратная связь",
  "messages": { "required": "Обязательное поле", "minLength": "Минимум {min} символов" },
  "labels": { "continue": "Далее", "back": "Назад", "stepOf": "Шаг {current} из {total}" },
  "submit": { "label": "Отправить" },
  "fields": [{ "key": "name", "type": "text", "label": "Имя", "validation": { "required": true, "minLength": 2 } }]
}

Field messages such as minLengthMessage win over messages. All keys are in the specification.

Translation keys ​

Put your existing keys in i18n objects. The literal texts stay as the fallback.

json
{
  "id": "signup",
  "title": "Create your account",
  "i18n": { "messages": { "required": "errors.required" }, "labels": { "continue": "common.next" } },
  "submit": { "label": "Create account", "i18n": { "label": "signup.submit" } },
  "fields": [
    {
      "key": "password", "type": "password", "label": "Password",
      "i18n": { "label": "signup.password.label" },
      "validation": { "required": true, "minLength": 8, "i18n": { "minLength": "errors.password.tooShort" } }
    }
  ]
}

Texts without a key keep the source text with prefix: false. Without it, Formhaus also asks derived keys such as signup.title and signup.messages.minLength.

React with react-i18next ​

tsx
import { useTranslation } from 'react-i18next';
import { FormRenderer } from '@formhaus/react';
import { useLocalizedForm } from '@formhaus/react/i18n';
import definition from './signup.json';

export function Signup() {
  const { t, i18n } = useTranslation('forms');
  const form = useLocalizedForm(definition, {
    translate: (key, params, defaultValue) => t(key, { ...params, defaultValue }),
    prefix: false,
    lang: i18n.language,
  });
  return <FormRenderer {...form} onSubmit={save} />;
}

The hook rebuilds the form when lang changes. Pass other changes, such as a namespace that finished loading, as the third argument: useLocalizedForm(definition, source, [ready]).

Lokalise: export the forms namespace as nested JSON with i18next placeholders, so errors.password.tooShort can read Не менее символов.

Vue with vue-i18n ​

vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { FormRenderer } from '@formhaus/vue';
import { useLocalizedForm } from '@formhaus/vue/i18n';
import definition from './signup.json';

const { t, te, locale } = useI18n();
const form = useLocalizedForm(definition, () => ({
  translate: (key, params, fallback) => (te(key) ? t(key, params) : fallback),
  prefix: false,
  lang: locale.value,
}));
</script>

<template>
  <FormRenderer v-bind="form" @submit="save" />
</template>

te keeps vue-i18n from returning the key for a missing message. Set fallbackLocale: false in createI18n(), otherwise te also finds the key in the fallback locale and a missing text shows in that language instead of the source text. vue-i18n placeholders are {min}. Write @ as {'@'} in vue-i18n messages; a message that fails to compile falls back to the source text.

Overlays ​

An overlay is one JSON file per language with the translated texts, nested by step id, field key and option value.

json
{
  "title": "Создайте аккаунт",
  "submit": { "label": "Создать аккаунт" },
  "messages": { "required": "Обязательное поле", "minLength": "Минимум {min} символов" },
  "labels": { "continue": "Далее", "back": "Назад", "stepOf": "Шаг {current} из {total}" },
  "steps": {
    "account": {
      "title": "Аккаунт",
      "fields": {
        "email": { "label": "Почта", "validation": { "required": "Укажите почту" } },
        "plan": { "label": "Тариф", "options": { "free": "Бесплатный", "pro": "Про" } }
      }
    }
  }
}
tsx
import { useLocalizedForm, type Overlay } from '@formhaus/react/i18n';

export function Signup({ lang, overlay }: { lang: string; overlay?: Overlay }) {
  const form = useLocalizedForm(definition, overlay && { overlay, lang });
  return <FormRenderer {...form} onSubmit={save} />;
}

Load the overlay for a language before rendering:

ts
async function loadOverlay(lang: string): Promise<Overlay | undefined> {
  return lang === 'en' ? undefined : (await import(`./signup.${lang}.json`)).default;
}

Missing or empty strings keep the source text, so an incomplete translation still works. A field message in the definition, such as required: "Enter your email", wins over messages of the overlay; translate it at the field address. The same file loads into i18next with i18n.addResourceBundle('ru', 'forms', { signup: overlay }), and derived keys then resolve through t.

Sections and groups have their own addresses. A section title lives at sections.<key>.title, or steps.<id>.sections.<key>.title in a multi-step form, and the fields inside keep their usual address. A group keeps fields.<key>.label and adds itemLabel, addLabel and removeLabel. Its item fields live under fields.<key>.fields.<item key>. The labels catalog has addItem, removeItem, itemLabel ({label} {index}), itemAdded, itemRemoved and removeItemNamed, removeItemPrompt ({item}), and removeItemBody and cancel. A translated itemLabel must keep {index}.

json
{
  "sections": { "billing": { "title": "Платёжный адрес" } },
  "fields": {
    "participants": {
      "itemLabel": "Участник {index}",
      "addLabel": "Добавить участника",
      "fields": { "email": { "label": "Почта" } }
    }
  },
  "labels": { "removeItem": "Удалить", "itemRemoved": "{item} удалён" }
}

Check a translation before you ship it:

ts
import { textSlots, translationIssues, translationTemplate } from '@formhaus/core/i18n';

const template = translationTemplate(definition);
const { missing, unknown, invalid, placeholderMismatch, unlabeled } = translationIssues(definition, overlay);

textSlots(definition) lists every text with its address, key, placeholders and a hash. Store the hashes next to the overlay, such as in signup.ru.meta.json, and translationIssues(definition, overlay, { slots }) also reports stale texts whose source changed.

Translation management ​

ToolSettings
LokaliseNested JSON. Numeric option values such as "0" export as arrays unless the key separator is :::.
CrowdinNested JSON. Quote identifiers with dots.
PhraseNested JSON with preserve_data_types off and plural detection off. Phrase rebuilds nesting from dots.

Use letters, digits, - and _ in form ids, step ids, field keys and option values. checkTranslations() warns about . and : once the definition has translation keys.

Server ​

Validate with the localized definition so 422 errors come back in the user's language:

ts
import { localize } from '@formhaus/core/i18n';
import { validateSubmission } from '@formhaus/core/server';

const { definition: localized, messages } = localize(definition, { overlay: overlays[lang] });
const { errors } = validateSubmission(localized, body.values, { skippedSteps: body.skippedSteps, messages });

Agents ​

The MCP tool translation_template returns the overlay skeleton and the slot list for a language. simulate_path takes an overlay to run the form in that language. See MCP.

Limits ​

  • The language is set when the form is created. A new language recreates the form and clears its answers.
  • Options from optionsFrom and messages from custom validators are not translated here; translate them in the provider or the validator.
  • Templates have no plural forms. A translate function can choose the plural itself.
  • lang does not set dir or right-to-left layout.
  • localize() and useLocalizedForm() throw on the errors of checkTranslations(), such as an i18n key for a text the definition does not have. The same definition renders without translations.

Formhaus is MIT licensed. Legal