Translations
A definition keeps texts in its source language. Translate it with your app's keys or with an overlay file per language.
npm install @formhaus/core @formhaus/react # or @formhaus/vueMessages and labels
messages and labels replace the built-in English texts. {min}, {max}, {current} and {total} are filled in.
{
"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.
{
"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
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
<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.
{
"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": "Про" } }
}
}
}
}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:
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}.
{
"sections": { "billing": { "title": "Платёжный адрес" } },
"fields": {
"participants": {
"itemLabel": "Участник {index}",
"addLabel": "Добавить участника",
"fields": { "email": { "label": "Почта" } }
}
},
"labels": { "removeItem": "Удалить", "itemRemoved": "{item} удалён" }
}Check a translation before you ship it:
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
| Tool | Settings |
|---|---|
| Lokalise | Nested JSON. Numeric option values such as "0" export as arrays unless the key separator is :::. |
| Crowdin | Nested JSON. Quote identifiers with dots. |
| Phrase | Nested 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:
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
optionsFromand 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.
langdoes not setdiror right-to-left layout.localize()anduseLocalizedForm()throw on the errors ofcheckTranslations(), such as ani18nkey for a text the definition does not have. The same definition renders without translations.