Internationalizing Forms & Input

A German customer types 1.234,56 into a price field and the application stores one point two three. A Dutch customer’s postcode 1012 AB is rejected by a five-digit rule. A Japanese address is entered into fields ordered for the United States and arrives at the warehouse unusable. A person with one name cannot get past a required last-name field.

Forms are where a localized product stops being about translated strings. The interface may be perfectly translated and still be unusable, because the shape of the data it demands is the shape of one country.

Five form assumptions and who they exclude A split first and last name field breaks for mononyms, patronymic systems and family-name-first cultures. A five-digit postcode pattern rejects the United Kingdom, Canada and the Netherlands. A required state field is meaningless across most of Europe. A ten-digit phone rule fits one country. And a fixed address line order is wrong in Japan, Hungary and Brazil. Assumptions a form makes about a person Breaks for What to do instead First name + last name mononyms, patronymics, Chinese order one full-name field Postcode is 5 digits UK, Canada, Netherlands free text, validated per country State is required most of Europe required per country, not globally Phone is 10 digits every country but one E.164, validated by library Address line order is fixed Japan, Hungary, Brazil order driven by country
Each row is a validation rule that reads as reasonable and is a hard failure for a real customer.

This page belongs to Framework i18n & Component Routing: the fields are components, their order is layout, and the values they produce have to survive the round trip described in Unicode text handling and collation.

Prerequisites

Concept & spec — country decides structure, locale decides presentation

The single most useful distinction in form internationalization is that country and locale answer different questions, and conflating them produces most of the failures.

The country decides structure: which address fields exist, in what order, what they are called, which are required, what a valid postcode looks like, whether a state or prefecture is meaningful. A German address form has a postcode-then-city line whether the reader’s interface is in German or English.

The locale decides presentation: which language the labels are in, how a typed number is punctuated, how a date is written, which direction the layout flows. A Swiss reader using an English interface still types 1'234.56 if that is what their keyboard and habits produce.

Getting this wrong in either direction is visible. Deriving the address form from the interface language gives an American living in Germany a US address form. Deriving number parsing from the shipping country gives a German expatriate in the United States a parser that misreads their input.

Four countries, four address orders American addresses run street, city, state and ZIP. British addresses run street, town, county and postcode. German addresses put the house number after the street and the postcode before the city on one line. Japanese addresses run from the largest unit to the smallest, starting with the postcode and prefecture. Address field order by country Order readers expect United States street, city, state, ZIP United Kingdom street, town, county, postcode Germany street + number, postcode + city Japan postcode, prefecture, city, street, building
Japan is not a variation on the others — it is the reverse, which is why a single fixed layout cannot serve both.

The parse gap

The platform gives you formatting and no parsing. Intl.NumberFormat turns 1234.56 into 1.234,56 for German; nothing in the standard library turns 1.234,56 back into a number. Number('1.234,56') is NaN, and parseFloat returns 1.234 — a wrong answer with no error, which is worse.

Round trip of a numeric input across locales A reader types a number using their locale conventions, the application parses it into a machine number, stores it as an exact integer in minor units, and formats it back for display. Only the parse step has no built-in support, which is why it is where locale-specific input breaks. One number, three representations Typed 1.234,56 parse Parsed 1234.56 store Stored 123456 minor units format Rendered 1.234,56 € The parse step is the one no platform provides — Intl formats, but it does not read.
Three of the four arrows have platform support. The first does not, and that is where the bugs are.

The reliable technique is to ask the formatter what its own separators are, then invert them.

export function parseLocaleNumber(input: string, locale: string): number | null {
  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
  const group = parts.find((p) => p.type === 'group')?.value ?? ',';
  const decimal = parts.find((p) => p.type === 'decimal')?.value ?? '.';

  const cleaned = input
    .trim()
    .replace(new RegExp(`\\${group}`, 'g'), '')   // drop grouping
    .replace(decimal, '.')                       // canonical decimal point
    .replace(/[^\d.\-]/g, '');                    // drop currency symbols, spaces

  if (!cleaned || Number.isNaN(Number(cleaned))) return null;
  return Number(cleaned);
}

Deriving the separators from formatToParts rather than hardcoding them per locale is what makes this correct for locales you have not thought about — including the ones using a narrow no-break space as a group separator, which no hand-written table remembers.

Step-by-step implementation

1. Put the country selector first, and re-render from it

const [country, setCountry] = useState<CountryCode>(guessFromLocale(locale));
const schema = ADDRESS_SCHEMAS[country];   // labels, order, required, pattern

return (
  <form>
    <CountrySelect value={country} onChange={setCountry} />
    {schema.fields.map((f) => (
      <Field key={f.name} {...f} label={t(`address.${f.name}`)} />
    ))}
  </form>
);

The schema drives order, labels and requirements together. A field that is state in the United States is province in Canada, prefecture in Japan and absent in Germany — and that is a data difference, not four components.

Five steps for an internationalized form The country selector comes first because it determines the shape of everything after it. Fields are rendered from a per-country schema carrying labels, order, requirements and validation. Input is parsed using the reader locale conventions rather than a fixed pattern. Values are stored in canonical machine formats and rendered back localized. And validation messages are authored as ICU messages so they translate correctly. Building an input that travels 1 Ask the country first it decides every other field 2 Render fields from a country schema labels, order, required, validation 3 Parse input with the reader locale decimal comma, digit shapes, spaces 4 Store canonical, render localized E.164, minor units, ISO dates 5 Write validation messages as ICU so counts and genders survive translation
Step one is a layout decision as much as a data one: everything below it re-renders when it changes.

2. Store canonical, render localized

Every value with an international standard should be stored in it: phone numbers in E.164 (+493012345678), dates as ISO calendar dates, money as integer minor units with an ISO 4217 code, countries as ISO 3166-1 alpha-2. Rendering converts on the way out.

// Phone: the input is whatever the reader typed; storage is E.164.
import { parsePhoneNumber } from 'libphonenumber-js';

const parsed = parsePhoneNumber(rawInput, country);
if (!parsed?.isValid()) return { error: t('form.phone.invalid') };
await save({ phone: parsed.number });         // "+493012345678"
render(parsed.formatNational());              // "030 12345678"

3. Treat names as one field unless you have a reason not to

A single fullName field with a free-text value handles mononyms, patronymics, multiple family names, and cultures where the family name comes first. Where a system genuinely needs a sortable component — a legal document, a boarding pass — ask for it explicitly as a separate, clearly labelled field rather than trying to derive it by splitting on a space.

4. Write validation messages as messages, not as concatenations

{field, select,
  postcode {{country, select,
    DE {Enter a 5-digit postcode}
    GB {Enter a UK postcode, for example SW1A 1AA}
    other {Enter a valid postcode}}}
  other {This field is required}}

Validation text is user-facing copy with the same plural and gender constraints as anything else. Building it by concatenating a field name and a fragment produces exactly the fragmented sentences that cannot be translated — the problem described in pluralization rules across languages.

5. Handle composition events for CJK input

An input method editor composes characters over several keystrokes. Validating or transforming the value mid-composition corrupts it and jumps the cursor.

let composing = false;
input.addEventListener('compositionstart', () => { composing = true; });
input.addEventListener('compositionend', () => { composing = false; validate(input.value); });
input.addEventListener('input', () => { if (!composing) validate(input.value); });

Any live validation, auto-formatting or character counter needs this guard. Without it, typing Japanese into a field with an auto-formatter is close to impossible.

Configuration reference

Concern Canonical storage Driven by
Phone number E.164 string country for parsing, locale for display
Address per-country field set country
Name single free-text field neither — do not split
Money integer minor units + ISO 4217 currency for digits, locale for display
Date ISO 8601 calendar date locale for entry and display
Country ISO 3166-1 alpha-2 the reader’s choice
Postcode free text, per-country validation country
Number entry machine number locale for parsing

Framework variants

React. Controlled numeric inputs are where the parse gap bites: storing the parsed number and rendering it formatted fights the reader mid-typing, because a partially typed value is not yet parseable. Hold the raw string in state, parse on blur or on submit, and format only when the field is not focused.

Vue / Nuxt. The same rule applies with v-model, and a custom modifier is a clean place to put it — the model holds the machine value while the input holds the raw text.

Angular. Reactive forms make the per-country schema natural: build the FormGroup from the schema and rebuild it when the country changes. Validators become per-country too, which is easier to express here than in most frameworks.

Server-side validation. Everything above must be repeated on the server, because a client-side schema is a convenience rather than a guarantee. The server should validate the canonical form — E.164, ISO date, integer amount — which is simpler than validating localized input, and is another argument for canonicalising at the boundary.

Verification

test.each([
  ['de-DE', '1.234,56', 1234.56],
  ['en-US', '1,234.56', 1234.56],
  ['fr-FR', '1 234,56', 1234.56],   // narrow no-break space
  ['de-CH', '1’234.56', 1234.56],   // apostrophe group separator
])('%s parses %s', (locale, typed, expected) => {
  expect(parseLocaleNumber(typed, locale)).toBe(expected);
});

test('a Japanese address renders in Japanese order', () => {
  const { getAllByRole } = render(<AddressForm country="JP" />);
  const labels = getAllByRole('textbox').map((i) => i.getAttribute('name'));
  expect(labels).toEqual(['postalCode', 'prefecture', 'city', 'street', 'building']);
});

The Swiss row in the first test is the one that catches hardcoded separator tables, and the French row catches code that treats a space as whitespace to be stripped before parsing rather than as a group separator.

Where the field schemas come from

Writing per-country address schemas by hand is a project nobody finishes. There are around two hundred and fifty territories, several of them with genuinely unusual conventions, and the data changes as postal authorities change their rules.

Three sources are worth knowing about. Published address data derived from postal authorities covers field presence, order, labels and validation patterns for most territories, and is what large checkout implementations are built on. Payment providers expose address requirements per country through their APIs, which is a pragmatic source if you already integrate one. And your own shipping data tells you which territories actually matter — most products discover that fifteen countries cover the overwhelming majority of orders, which makes hand-curating those fifteen and defaulting the rest an entirely reasonable strategy.

Whichever source you use, treat the schema as data rather than as code. A schema that lives in a JSON file can be updated without a deploy, reviewed by someone who is not an engineer, and diffed when a postal authority changes a rule. A schema expressed as branching logic inside a component cannot.

The one part that should not be data-driven is the fallback. When a country has no schema, the form should render a generic international shape — street address, city, region, postcode, all but the first two optional — rather than the schema of whichever country the code was written in. That single decision converts “unusable” into “slightly generic” for every territory you have not curated.

Autofill, and why field names matter

Browser and password-manager autofill is the fastest path through a form for most readers, and it is driven entirely by the autocomplete attribute rather than by your field names or labels.

The attribute values are standardised and international by design: country-name, postal-code, address-level1 and address-level2 deliberately avoid country-specific words like “state” and “county”. Using them means a reader’s stored address fills correctly regardless of which country’s form they are looking at, and it means a screen reader announces a purpose rather than a label.

The subtlety is that autofill fills canonical values that may not match your validation. A stored phone number arrives in whatever format the reader saved it, and a stored country may arrive as a full name rather than a code. Parsing autofilled values with the same tolerance you apply to typed ones — rather than rejecting them — is what keeps autofill from becoming a source of failed submissions.

For name fields, autocomplete="name" on a single field is both simpler and more likely to fill correctly than given-name and family-name on two, because it does not require the browser to have split the name the same way you did.

Common pitfalls

  • Deriving the address form from the interface language. An American in Germany gets the wrong form; a German using English gets the wrong one too.
  • parseFloat on localized input. Returns a plausible wrong number for every comma-decimal locale.
  • Splitting a name on a space. Wrong for mononyms, for multiple family names, and for family-name-first cultures.
  • A postcode regular expression that is not per country. The most common cause of a customer who cannot check out.
  • Required state or province. Meaningless in most of Europe, and a hard block when enforced globally.
  • Live validation during composition. Makes CJK input unusable; guard on composition events.
  • Storing what the reader typed. Localized input is not a format; canonicalise at the boundary.

Error messages that survive translation

Validation copy is the part of a form most likely to be assembled from fragments, because it is written last, under pressure, by whoever is fixing the validation.

The fragment pattern looks harmless in English. A field name, a space, and a reason — “Postcode is required”, “Email is invalid” — reads fine and produces a sentence in one language only. In German the field name inflects; in Japanese the particle depends on the noun; in Russian the adjective agrees with the gender of the field name. Every one of those is unexpressible when the two halves live in different catalogue entries.

The rule is the same one that governs every other message: the whole sentence lives in one entry. That means a message per field per condition, which feels verbose and is exactly what makes translation possible. Translation memory absorbs most of the repetition, since the messages are similar to each other, and the resulting catalogue is one a translator can actually work with.

Two further properties are worth building in from the start. Messages should name what to do, not what went wrong — “Enter a five-digit postcode” is actionable where “Invalid postcode” is not, and the actionable form usually needs the country context anyway, which pushes you toward the per-country structure above. And counts belong in ICU plurals: “Enter at least {min, plural, one {# character} other {# characters}}” is one entry that is correct in every language, whereas a message with a bare number and a hardcoded plural is wrong in most of them.

Finally, associate the message with the field programmatically rather than only visually. An error rendered next to a field but not linked by aria-describedby is invisible to a screen reader, which turns a validation problem into an accessibility one — and that failure is identical in every language.

FAQ

Should the country come from geolocation?

Offer it, do not impose it. Geolocation is a reasonable default for the selector and a poor authority: travellers, expatriates and anyone shipping to another country all break it. Whatever it suggests, the reader must be able to change it, and changing it must re-render the form.

Is a single full-name field really enough?

For most products, yes — and where it is not, the requirement is usually narrower than it appears. Sorting a customer list can use a collator on the full name; addressing an email can use the whole name. Reserve a split only for interfaces with a genuine external constraint, and label the parts by what they legally are rather than as first and last.

How do I validate an email address internationally?

Loosely. Internationalized addresses can carry non-ASCII local parts and internationalized domain names, so a strict pattern rejects valid addresses. Check for a single at-sign with something either side, then confirm by sending mail — which is the only real validation there has ever been.

What about date input?

Prefer a date picker that produces an ISO value, because typed dates are genuinely ambiguous: 03/04/2026 is two different days depending on the reader. If a text field is required, state the expected order in the field’s help text and parse it with the resolved locale rather than a fixed pattern.

Do I need per-country address schemas for every country?

No. A reasonable default plus explicit schemas for the countries you actually ship to covers most of the value. What matters is that the default is a generic form — street, city, region, postcode, all optional except street and city — rather than one country’s form pretending to be universal.

How should a form behave when the reader changes country mid-entry?

Preserve what still applies and clear what does not. The street line and the name survive a change from Germany to Japan; the postcode format, the region field and the validation rules do not. Silently keeping a value that is no longer valid produces a submission error the reader cannot explain, and clearing everything punishes them for a correction. Mapping the fields that exist in both schemas and clearing the rest is the behaviour readers expect.

Does any of this apply to a single-market product?

Less of it, and not none. A product serving one country still receives pasted text in other scripts, still has customers with mononyms and international phone numbers, and still benefits from canonical storage. The parts that can reasonably be skipped are the per-country schemas and the country selector; the parts about names, canonical formats and message construction apply regardless.

Part of Framework i18n & Component Routing.