Multilingual fields (i18n)
The form engine supports translated fields natively. The value of a translatable field is written to model.translations[lang].fieldKey instead of model.fieldKey.
TL;DR
const fields: FieldDescriptor<Article>[] = [
{ key: "title", type: "text", translatable: true },
{ key: "body", type: "editor", translatable: true },
{ key: "slug", type: "text" }, // not translated
];<MapoForm
v-model="article"
:fields="fields"
:languages="['it', 'en']"
v-model:current-lang="activeLang"
/>When languages has more than one entry, <MapoForm> shows a language tab bar at the top. Switching tabs swaps every translatable field's value source.
How to: shape the model
interface Article {
slug: string; // not translated
is_active: boolean; // not translated
translations: {
it: { title: string; body: string };
en: { title: string; body: string };
};
}You do not need to pre-populate every language: when the user types in a translatable field for a language that does not yet exist on model.translations, the form engine initializes the empty object automatically (initLang). You can ship { translations: {} } from the backend safely.
How to: control currentLang from the page
v-model:current-lang makes the active language a regular two-way binding, so you can read it elsewhere on the page or even programmatically switch:
<script setup>
const activeLang = ref("it");
function switchToEnglish() {
activeLang.value = "en";
}
</script>
<template>
<UButton @click="switchToEnglish">Switch to EN</UButton>
<MapoForm v-model:current-lang="activeLang" ... />
</template>How to: read a translated value outside the form
const currentTitle = computed(
() => article.value.translations?.[activeLang.value]?.title ?? "",
);How to: drive the language from the route
For deep-linkable language switches:
const route = useRoute();
const router = useRouter();
const activeLang = computed({
get: () => (route.query.lang as string) ?? "it",
set: (val) => router.replace({ query: { ...route.query, lang: val } }),
});<MapoDetail> already does this for you — ?lang=it syncs with the language tab automatically.
How to: use it headless
const activeLang = ref("it");
const form = useMapoForm({
model,
fields,
languages: ["it", "en"],
currentLang: activeLang,
registry: $mapoFormRegistry,
});
const currentTitle = computed(
() => model.value.translations?.[activeLang.value]?.title,
);How to: handle nested errors from the backend
DRF and drf-spectacular typically return errors in the same nested shape as the value:
{
"translations": {
"it": { "title": ["This field is required."] }
}
}The path matcher in resolveErrors walks dotted segments automatically — the error renders under the right field in the right language tab. No flattening required.
How to: mix translated and non-translated fields
Fields with translatable: true write to translations[lang]. Fields without it write to the root of the model. They live side-by-side without conflict:
const fields: FieldDescriptor<Article>[] = [
{ key: "is_active", type: "switch", label: "Active" }, // model.is_active
{ key: "slug", type: "text", translatable: true }, // model.translations[lang].slug
{ key: "title", type: "text", translatable: true }, // model.translations[lang].title
{ key: "created_at", type: "datetime" }, // model.created_at
];How to: show a per-language error badge on the language tab
The <MapoDetailLangSwitch> (used inside <MapoDetail>) already paints a red badge on a language tab when at least one field has an error inside translations.<lang>.*. Inside a bare <MapoForm>, the badge appears on the field, not on the language tab — keep <MapoDetail> if you want per-language summaries.
Full template
<script setup lang="ts">
import type { FieldDescriptor } from "mapomodule/types";
interface PageModel {
is_active: boolean;
translations: Record<
string,
{ title: string; content: string; slug: string }
>;
}
const page = ref<PageModel>({
is_active: true,
translations: { it: { title: "", content: "", slug: "" } },
});
const fields: FieldDescriptor<PageModel>[] = [
{ key: "is_active", type: "switch", label: "Active" },
{ key: "title", type: "text", translatable: true, required: true },
{ key: "slug", type: "text", translatable: true },
{ key: "content", type: "editor", translatable: true },
];
const errors = ref({});
const lang = ref("it");
</script>
<template>
<MapoForm
v-model="page"
:fields="fields"
:languages="['it', 'en']"
v-model:current-lang="lang"
:errors="errors"
/>
</template>Pitfalls
translatablecannot be toggled at runtime per descriptor instance — the value path is decided when the descriptor is read. Replace the entire descriptor if you must flip behavior.- Mixing translated and non-translated keys with the same name — a field declared without
translatablewrites tomodel.foo; a translatable one writes tomodel.translations[lang].foo. They are distinct values; the schema must keep them separate. - Backend payload shape — when sending, ship the full
translationsobject. The form does not split it for you. If your API expects a flat{ title, ...lang }shape, transform it insubmit().