@mackehansson/schemaform

i18n

Externalize labels, titles, validation messages, and renderer chrome with the translate hook.

Form Definitions store default strings: field labels, Page titles, Group titles, placeholders, descriptions, and validation messages. Renderers pass those strings through an optional translate(key, defaultText, params) callback before displaying them.

That keeps the persisted Form Definition simple and storage-agnostic. Your app can plug in any i18n library, or omit translate and render the defaults everywhere.

Translate a rendered form

The first-party ShadCN Renderer accepts translate directly.

import { SchemaForm } from "@mackehansson/schemaform-shadcn";
import type { FormDefinition, TranslateFn } from "@mackehansson/schemaform";

const messages: Record<string, string> = {
  "#/properties/email.label": "E-post",
  "#/properties/email.error.required": "Ange din e-postadress",
  "#/properties/email.error.format": "Ange en giltig e-postadress",
  "page.contact.title": "Kontakt",
  "ui.submit": "Skicka",
};

const translate: TranslateFn = (key, defaultText) => {
  return messages[key] ?? defaultText;
};

export function ContactForm({ definition }: { definition: FormDefinition }) {
  return (
    <SchemaForm
      definition={definition}
      translate={translate}
      onSubmit={(payload) => console.log(payload)}
    />
  );
}

Keys are derived from structure, not from the current rendered text. That means translators can replace defaults without changing the Form Definition.

Common keys

Field labels use the Control scope:

"#/properties/email.label";

Field errors include the JSON Schema keyword or custom validator keyword:

"#/properties/email.error.required";
"#/properties/email.error.format";
"#/properties/username.error.custom";

Page titles use the Page id when present, falling back to its structural index:

"page.contact.title";
"page.[0].title";

Group title keys require a Group id:

"group.billing.title";

Renderer chrome uses ui.* keys, such as ui.submit, ui.next, ui.back, ui.validating, and ui.errorSummary.

Interpolation params

The third argument contains constraint values from validation errors. Use it with your i18n library's interpolation format.

import type { TranslateFn } from "@mackehansson/schemaform";

const translate: TranslateFn = (key, defaultText, params) => {
  if (key === "#/properties/name.error.minLength") {
    return `Use at least ${String(params?.limit)} characters`;
  }

  return defaultText;
};

The default text is always passed in, so missing translations degrade cleanly.

Headless renderers

If you build your own Renderer with useFormRenderer, import the key helpers from Core and call your translation function where you display text.

import {
  fieldErrorKey,
  fieldLabelKey,
  pageTitleKey,
  type TranslateFn,
} from "@mackehansson/schemaform";

function renderLabel(scope: string, defaultLabel: string, t: TranslateFn) {
  return t(fieldLabelKey(scope), defaultLabel);
}

function renderError(
  scope: string,
  keyword: string,
  message: string,
  t: TranslateFn,
) {
  return t(fieldErrorKey(scope, keyword), message);
}

function renderPageTitle(
  id: string | undefined,
  index: number,
  title: string,
  t: TranslateFn,
) {
  return t(pageTitleKey(id, index), title);
}

Schemaform does not persist per-locale translation maps in v1. The translate hook is the stable integration point; a future translations map can reuse the same keys without changing how renderers ask for text.

On this page