Skip to content

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

ts
const fields: FieldDescriptor<Article>[] = [
  { key: "title", type: "text", translatable: true },
  { key: "body", type: "editor", translatable: true },
  { key: "slug", type: "text" }, // not translated
];
vue
<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

ts
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:

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

ts
const currentTitle = computed(
  () => article.value.translations?.[activeLang.value]?.title ?? "",
);

How to: drive the language from the route

For deep-linkable language switches:

ts
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

ts
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:

json
{
  "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:

ts
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

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

  • translatable cannot 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 translatable writes to model.foo; a translatable one writes to model.translations[lang].foo. They are distinct values; the schema must keep them separate.
  • Backend payload shape — when sending, ship the full translations object. The form does not split it for you. If your API expects a flat { title, ...lang } shape, transform it in submit().

Released under the MIT License.