arkynChangelogGuides
arkyn
docs / guides / how-to-localize-inputs-with-templates

Localize inputs with templates

@arkyn/templates doesn't export any components — it's the data layer meant to be plugged into Select, PhoneInput, and CurrencyInput from @arkyn/components. This guide shows how the pieces are meant to fit together, and clears up a couple of naming details that are easy to trip over.

Brazilian states → Select

brazilianStates already matches Select's options shape ({ label, value }) exactly, so no mapping step is needed:

tsx

import { Select } from "@arkyn/components/select";
import { brazilianStates } from "@arkyn/templates";
<Select name="state" label="State" options={brazilianStates} />

Countries → Select

countries needs a small mapping step, since its objects carry more fields than Select expects:

tsx

import { Select } from "@arkyn/components/select";
import { countries } from "@arkyn/templates";
const countryOptions = countries.map((country) => ({
label: `${country.name} (${country.code})`,
value: country.iso,
}));
<Select name="country" label="Country" options={countryOptions} isSearchable />
PhoneInput already has its own country list
PhoneInput consumes @arkyn/templates' countries internally for its built-in country selector (defaultCountryIso) — you don't need to wire that yourself. Reuse the same countries list for any other field on the same form (like a "shipping country" Select) instead of hand-rolling a second list, so both fields stay in sync if Arkyn ever updates its data.

Currency codes → CurrencyInput

This is the one place naming gets confusing: CurrencyInput's prop is called locale, but it actually expects a currency code ("USD", "BRL", "EUR", …) — the same strings used as keys in countryCurrencies — not a language-region locale string like "pt-BR". countryCurrencies' own countryLanguage field is the actual BCP-47 locale, meant for Intl.NumberFormat, and is a different value entirely.

tsx

import { CurrencyInput } from "@arkyn/components/currencyInput";
import { countryCurrencies, maximumFractionDigits } from "@arkyn/templates";
// countryCurrencies.BRL = { countryLanguage: "pt-BR", countryCurrency: "BRL" }
function PriceField({ currencyCode }: { currencyCode: keyof typeof countryCurrencies }) {
const { countryLanguage, countryCurrency } = countryCurrencies[currencyCode];
return (
<>
{/* CurrencyInput's "locale" prop takes the currency code, not countryLanguage */}
<CurrencyInput name="price" locale={countryCurrency as any} label="Price" />
{/* Use countryLanguage for anything formatted with Intl.NumberFormat elsewhere on the page */}
<span>
{new Intl.NumberFormat(countryLanguage, {
style: "currency",
currency: countryCurrency,
maximumFractionDigits,
}).format(1234.5)}
</span>
</>
);
}
If you're driving CurrencyInput's locale dynamically from a country/currency picker, derive it from Object.keys(countryCurrencies) (or from the selected country's currency) rather than hardcoding a separate list of supported currencies — that way both stay backed by the same source of truth.

Phone numbers: keeping the format consistent end to end

PhoneInput's hidden submitted value is the selected country's dialing code concatenated directly with the entered digits — e.g. +5511999999999 — which is the same E.164-style shape that @arkyn/shared's findCountryMask/formatToPhone and @arkyn/server's validatePhone expect as input. This isn't a coincidence: all three read from the same @arkyn/templates countries list.

tsx

// Client: PhoneInput submits "+5511999999999" as-is
<PhoneInput name="phone" label="Phone" defaultCountryIso="BR" />

typescript

// Server: validate and format the exact same value, no reformatting needed
import { validatePhone } from "@arkyn/server/validatePhone";
import { formatToPhone } from "@arkyn/shared/formatToPhone";
const phone = formData.get("phone") as string; // "+5511999999999"
if (!validatePhone(phone)) {
throw new BadRequest("Invalid phone number");
}
const displayPhone = formatToPhone(phone); // "(11) 99999-9999"
Don't hand-roll a second mask lookup
If you need to re-derive a display mask for a phone number you already have in E.164 form (for example, one loaded from the database rather than freshly submitted by PhoneInput), use findCountryMask instead of writing your own country/mask matching — it already resolves the correct entry from the same countries template.

Notes

  • countries, brazilianStates, and countryCurrencies are plain data — there's no fetch, no async loading, and no peer dependency required to use them (unlike findCountryMask/formatToPhone, which need libphonenumber-js).
  • Some countries' mask field in countries is an array (currently only Brazil, for the with/without-ninth-digit variants) rather than a single string — code that reads country.mask directly should handle both shapes if it bypasses PhoneInput/findCountryMask.
  • See Build forms with server-side validation for how a failed validatePhone check turns into a fieldErrors entry the client can display.
  • See Integrate address search and mapsSearchPlaces' returned stateShortName lines up with brazilianStates' value field, useful if you need to reconcile a free-text address search with a fixed state Select on the same form.
Related in Guides
On this page