Skip to main content

Address Form

The storefront collects every address through one component, rendered from the address schema of the chosen country (addressSchema(country, locale, surface)): the country select first, then the country's fields in the record's row order, geographic levels as selects that load their options when the parent is chosen. The same component serves three surfaces.

SurfaceUsed bynamePrefixCountries offered
accountthe address book (MyAddresses)''countries(scope: SELL_TO)
shippingthe checkout shipping step (Shipment)shippingAddresscountries(scope: SHIPPING) — sell-to ∩ zone countries
billingthe checkout billing step (BillingAddress)billingAddresscountries(scope: SELL_TO)

Usage​

import CustomerAddressForm from '@components/frontStore/customer/address/addressForm/Index';

// Inside a react-hook-form <Form> (or a FormProvider)
<CustomerAddressForm surface="shipping" namePrefix="shippingAddress" address={cart.shippingAddress} />
PropTypeDescription
surface'account' | 'shipping' | 'billing'Which form. Picks the country scope and the Area id suffix. Default account.
namePrefixstringField-name prefix in the form: shippingAddress.locality, or locality when empty. Default ''.
addressAddressGraphql | nullA stored address to edit (the GraphQL shape with formatted). Omit for a new address; the store's default country is then pre-selected.
countryScope'SHIPPING' | 'SELL_TO' | 'ALL'Override the scope the surface implies.

The container fetches the countries for the scope and the schema for the current country, keeps the last schema mounted while the next loads, and hands everything to the renderer. The field values submit as the shared address columns (recipient, address_line_1, locality, administrative_area, postal_code, country, telephone, …) plus registered extra fields.

Behavior​

  • Country first, then the country's fields. Hong Kong shows an area select and no postal code; Germany no administrative area; the United States a state select and a ZIP code with its pattern.
  • One country offered, no select. When the surface offers exactly one country (a sell-to list of one, or a single zone country for shipping) the country renders as a read-only line with that value, still inside addressField.country, and the schema fetched is already that country's.
  • Lazy regions. An enumerated level fetches regions(country, parentPath) when its parent has a value and is disabled until then. A stored key the data has retired is listed as "no longer available" until the customer picks again.
  • Country swap. Changing the country clears a field only when it is absent from the new schema or its type, option source or pattern changed; everything else is kept. Region selects always clear, because their keys are country-scoped.
  • Validation. required and pattern come from the schema and run in the browser; region keys and cross-field rules run on the server, whose error.errors[] land on the inputs through form.setError.
  • Live preview. The lines the server will print (formatted) are shown under the fields with the same formatAddress the server uses.
  • Server rendering. The form renders its loading skeleton on the server; the first client render matches it, then the schema arrives.

Customizing it — four seams, cheapest first​

1. CSS​

.address-fields__grid (a 12-column grid), .address-fields__field[data-row][data-field] per field, .address-preview.

2. Areas​

Area idContainsCore sort orders
addressForm.account, addressForm.shipping, addressForm.billingthe whole block for one surfaceaddressFields 10, addressPreview 20
addressField.<id> (e.g. addressField.telephone)one fieldthe core renderer at 10 — register at 5 to put something above it, 15 below
// themes/<id>/src/pages/frontStore/checkout/TelephoneHint.tsx
export default function TelephoneHint() {
return <p className="text-xs text-muted-foreground">{_('We only call about your delivery')}</p>;
}
export const layout = { areaId: 'addressField.telephone', sortOrder: 15 };

Area injections are registered per route, so the same decoration on all three surfaces is registered on each route.

3. The renderer map​

components/frontStore/customer/address/addressFieldRenderers.tsx maps a field type to a component: text → InputField, select → SelectField, tel → TelField, textarea → TextareaField, number → NumberField, email → EmailField. Shadow the file in your theme to replace one renderer — a dial-code telephone widget replaces tel and keeps the schema's validation:

// themes/<id>/src/components/frontStore/customer/address/addressFieldRenderers.tsx
export { TextRenderer, SelectRenderer, TextareaRenderer, NumberRenderer, EmailRenderer, FallbackRenderer, getAddressFieldRenderer } from '@evershop/evershop/components/frontStore/customer/address/addressFieldRenderers';
export const addressFieldRenderers = { ...core, tel: MyDialCodeTelField };

Every renderer receives AddressFieldProps: field (the schema field), name (prefixed), label (translated), required, placeholder, rules (react-hook-form rules from required and pattern), defaultValue, and for selects options, disabled, onChange. An unknown type falls back to a text input and warns once in the console.

4. A different layout​

Shadow components/frontStore/customer/address/AddressFields.tsx and implement AddressRendererProps — the public contract:

interface AddressRendererProps {
schema: ResolvedAddressSchema; // fields in order, with row, type, labelType, required, pattern, optionSource, dependsOn
namePrefix: string; // '', 'shippingAddress', 'billingAddress'
surface: 'account' | 'shipping' | 'billing';
countries: { value: string; label: string }[];
initialValues?: Record<string, string>; // a stored address by field id
onCountryChange?: (country: string) => void;
}

Use rulesFor(field, label) for the react-hook-form rules, useRegions(country, parentPath) for region options and the helpers in addressFormLogic.ts (fieldName, rowsOf, countrySwapPlan); the container keeps doing the schema fetching, the country query and the error mapping. A minimal renderer that submits is in core's rendererContract.test.tsx.