@vtex/country-forms
v1.1.0
Published
Headless dynamic form engine — single source of truth for address and personal data form schemas at VTEX
Maintainers
Keywords
Readme
@vtex/country-forms
A fully headless, framework-agnostic form engine that serves as VTEX's single source of truth for address and personal data forms. It provides declarative, per-country schemas with built-in synchronous validation, masks (format/unformat), and i18n with on-demand locale loading and consumer-level label overrides.
Zero runtime dependencies. Works in browsers, Node.js, and React Native.
Installation
# npm
npm install @vtex/country-forms
# yarn
yarn add @vtex/country-forms
# pnpm
pnpm add @vtex/country-formsQuick Start
import {
loadSchema,
loadLocale,
createFormState,
setValue,
validate,
getVisibleFields,
formatValue,
unformatValue,
resolveLabel,
} from '@vtex/country-forms'
// 1. Load schema and locale (code-split via dynamic import)
const schema = await loadSchema('BRA', 'address')
const i18n = await loadLocale('pt-BR')
// 2. Create form state
let state = createFormState(schema)
// 3. Render visible fields
const fields = getVisibleFields(schema, state.values)
fields.forEach(field => {
const label = resolveLabel(field.label, i18n)
// render your UI component with label, value, options, etc.
})
// 4. Update state on user input
state = setValue(state, schema, 'postalCode', '01001000')
// 5. Format for display
const display = formatValue(field.formatRef, '01001000') // → "01001-000"
// 6. Validate on submit
const errors = validate(state, schema)
if (Object.keys(errors).length === 0) {
// form is valid — unformat values before submitting to your API
const raw = unformatValue(field.unformatRef, display) // → "01001000"
}Both loadSchema and loadLocale use dynamic import() internally — only the requested country and locale enter your bundle.
API Reference
Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| loadSchema | (countryCode, formType) → Promise<FormSchema> | Loads a country-specific schema. Returns a generic fallback for unknown countries (never throws). |
| loadLocale | (locale) → Promise<I18nBundle> | Loads an i18n bundle. Falls back to en-US for unsupported locales (never throws). |
| createFormState | (schema, defaults?) → FormState | Creates initial form state from a schema, optionally pre-filled. |
| setValue | (state, schema, fieldName, value) → FormState | Returns a new state with the updated value. Automatically resets dependent fields with conditionalOptions. |
| validate | (state, schema) → FormErrors | Synchronous validation. Returns a sparse error map — empty object means valid. Hidden fields are skipped. |
| getVisibleFields | (schema, values) → FieldDefinition[] | Returns fields visible for the current values (evaluates conditionalVisibility). |
| formatValue | (ref, value) → string | Applies display mask (e.g. "01001000" → "01001-000"). Identity if ref is undefined. |
| unformatValue | (ref, value) → string | Strips mask for submission (e.g. "01001-000" → "01001000"). Identity if ref is undefined. |
| resolveLabel | (key, bundle, overrides?, params?) → string | Resolves i18n label. Fallback: override → bundle → raw key. params interpolates {name} placeholders (e.g. { fieldName, maxLength }). |
| resolveFieldOptions | (field, values) → SelectOption[] \| null | Resolves static or conditional options for a field. |
Constants
| Constant | Type | Description |
|----------|------|-------------|
| SUPPORTED_ADDRESS_COUNTRIES | readonly string[] | Countries with a dedicated address schema (['BRA', 'CAN', 'MEX', 'USA']). |
| SUPPORTED_PERSONAL_DATA_COUNTRIES | readonly string[] | Countries with a dedicated personal-data schema (['BRA']). |
| SUPPORTED_LOCALES | readonly string[] | Built-in locale tags (['en-US', 'es-MX', 'fr-FR', 'pt-BR']). |
Types
All types are exported from the package root:
import type {
FormSchema,
FieldDefinition,
FieldType, // 'text' | 'email' | 'select' | 'radio-group'
FormType, // 'address' | 'personal-data'
InputMode, // 'text' | 'numeric' | 'tel' | 'email'
ValidationRules,
ConditionalVisibility,
SelectOption,
FormValues,
FormErrors,
FormState,
I18nBundle,
I18nOverrides,
I18nParams,
SupportedLocale,
ValidatorFn,
FormatFn,
UnformatFn,
} from '@vtex/country-forms'Supported Countries
| Country | ISO3 | Address | Personal Data | Validators | Masks |
|---------|------|:-------:|:-------------:|------------|-------|
| Brazil | BRA | Yes | Yes | CEP, CPF, CNPJ, Email, Phone | CEP, CPF, CNPJ, Phone |
| United States | USA | Yes | — | ZIP | ZIP |
| Canada | CAN | Yes | — | Postal code | Postal code |
| Mexico | MEX | Yes | — | Postal code | — |
For unknown countries, loadSchema returns a generic fallback schema with basic address or personal-data fields.
Supported Locales
| Tag | Language |
|-----|----------|
| en-US | English (reference and default fallback) |
| pt-BR | Portuguese (Brazil) |
| es-MX | Spanish (Mexico) |
| fr-FR | French (France) |
loadLocale resolves regional variants to the closest match:
en-GB,en-AU→en-USes-419,es-ES→es-MXfr-CA→fr-FRpt-PT→pt-BR- Unknown language →
en-US
Example Usage
Country and locale selector
Use the constants to build dropdowns driven entirely by the library — no hardcoded lists in your application:
import {
SUPPORTED_ADDRESS_COUNTRIES,
SUPPORTED_LOCALES,
loadSchema,
loadLocale,
createFormState,
} from '@vtex/country-forms'
// Country selector options — always in sync with what the library supports
const countryOptions = SUPPORTED_ADDRESS_COUNTRIES.map(iso3 => ({
value: iso3,
label: iso3, // replace with your own display names
}))
// Locale selector options
const localeOptions = SUPPORTED_LOCALES.map(tag => ({
value: tag,
label: tag, // replace with your own display names, e.g. "Português"
}))
// Load schema and locale when the user selects a country/locale
async function onCountryChange(iso3: string, locale: string) {
const [schema, i18n] = await Promise.all([
loadSchema(iso3, 'address'),
loadLocale(locale),
])
const state = createFormState(schema)
// update your UI with schema, i18n, state
}Guard before loading (optional)
loadSchema never throws — it returns a generic fallback for unknown countries. You can still guard with the constant if you want to show different UI for unsupported countries:
import { SUPPORTED_ADDRESS_COUNTRIES, loadSchema } from '@vtex/country-forms'
if (SUPPORTED_ADDRESS_COUNTRIES.includes(iso3)) {
// dedicated schema with country-specific fields and validators
const schema = await loadSchema(iso3, 'address')
} else {
// generic fallback schema — loadSchema works here too, no need to skip
const schema = await loadSchema(iso3, 'address')
}i18n Overrides
Override any label or message without forking the library:
const overrides = {
'country-forms.address.street.label': 'Delivery street',
'country-forms.validation.required': 'This field is mandatory',
}
const label = resolveLabel(field.label, i18n, overrides)
// → "Delivery street" (override wins over bundle)Fallback order: consumer override → library bundle → raw key.
Consumer Responsibilities
Async Validation
The library provides synchronous validation only. Async validations such as postal code lookup via API, document verification against external services, or server-side uniqueness checks must be implemented in your application.
const errors = validate(state, schema)
// Add your async validations
const postalCode = state.values.postalCode
const apiResult = await myApi.validatePostalCode(postalCode)
if (!apiResult.valid) {
errors.postalCode = 'My custom async error message'
}UI Rendering
The library is headless — it never renders UI components. You map each field.type to your own component:
const renderers = {
text: MyTextInput,
email: MyEmailInput,
select: MySelect,
'radio-group': MyRadioGroup,
}
getVisibleFields(schema, state.values).forEach(field => {
const Component = renderers[field.type]
// render <Component label={...} value={...} options={...} />
})Persistence
How you save form data to your API or backend is your concern. The library defines form-layer field names only — it does not prescribe REST/GraphQL payload shapes or database schemas. If your API uses different property names, implement the mapping in your application.
Code-Splitting
The library is designed for tree-shaking and code-splitting:
loadSchema('BRA', 'address')only loads the BRA address schema and its rulesloadLocale('pt-BR')only loads the Portuguese bundle- Unused schemas, rules, and locales are excluded by modern bundlers (Webpack, Vite, etc.)
Bundle layout
The published dist/ ships an entry of about 9 KB (engine + types + i18n
glue) and one chunk per country / locale. Modern bundlers consuming this
package will create separate chunks for each import(), so a consumer that
loads only BRA + pt-BR pays for 2 chunks, not the entire library.
| Entry | Size (ESM) | Contents |
|-------|-----------:|----------|
| index.mjs | ~9 KB | Engine, types, registry shells, dynamic import() map |
| BRA-*.mjs | ~3 KB | Brazilian address schema |
| bra-*.mjs | ~2–4 KB | Brazilian validators / masks |
| chunk-*.mjs (BRA cities) | ~350 KB | Loaded only when loadSchema('BRA', …) resolves |
| pt-BR-*.mjs | ~4 KB | Portuguese locale bundle |
package.json declares "sideEffects": false, telling bundlers it is safe to
drop unreferenced re-exports. The published build is verified by an automated
tree-shake test in __tests__/integration/tree-shake.test.ts.
Quality gates
yarn verifyruns typecheck → lint → build → tests with coverage- Jest enforces
coverageThreshold: 80%per metric forsrc/engine/,src/rules/, andsrc/i18n/(current coverage: ~99% lines, 100% functions) - The quickstart in
docs/quickstart.mdis exercised end-to-end by__tests__/integration/quickstart.test.ts
