Skip to main content

Address Formats

Every address in EverShop — the customer's address book, the cart's shipping and billing addresses, the order's copies — is driven by one format record per country. The record says which lines the country uses and in which order; the server derives the form, the validation, the printed lines and the integration payloads from it. A package can change a country, add regions below it, or add a field, with calls from its bootstrap.ts and nothing else: no migration, no GraphQL, no React.

This guide covers the vocabulary and storage, the format records, region providers, how a schema is resolved, validation, display, GraphQL, the store settings, extra fields, integrations, the storefront form, legacy orders, a package recipe, and the upgrade from the pre-2026 address model.

Vocabulary and storage​

The three address tables — customer_address, cart_address, order_address — share one set of columns and stay identical (a check in the test suite enforces it).

ColumnLayout tokenMeaning
recipient%NFull name as printed. Always stored, composed from the parts when the store collects split names.
given_name, family_name—Name parts, only when the store collects them (addressNameFormat = split).
organization%OCompany.
address_line_1, address_line_2, address_line_3%AStreet lines. Line 3 only when enabled.
dependent_locality%DWard, neighbourhood or district below the city.
locality%CCity or town.
administrative_area%SState, province, prefecture, region.
postal_code%ZPostal or ZIP code.
sorting_code%XSorting code (CEDEX).
country—ISO 3166-1 alpha-2, upper-case.
telephone—E.164, normalized with the country's dial code.
extra—JSONB: values of extra fields registered by extensions, keyed by field id.

Geographic levels store the region key of the country's provider (US-CA, VN-SG, a Hong Kong area name, a Vietnamese ward code) where the level is enumerated, and free text where it is not. Names are resolved at read time; nothing snapshots them.

Format records​

A record has the shape of one entry of Google's open address metadata (libaddressinput), so a package can copy data from that reference without translating concepts:

import type { AddressFormat } from '@evershop/evershop/lib/address';

const US: AddressFormat = {
fmt: '%N%n%O%n%A%n%C, %S %Z', // layout: %n is a line break, tokens on one line share a row
require: 'ACSZ', // required tokens
upper: 'CS', // printed upper-case on an envelope (display only)
zip: '(\\d{5})(?:[ \\-](\\d{4}))?',
zipex: '95014,22162-1010', // the first example is the placeholder
state_name_type: 'state', // label types → "State", "ZIP code"
zip_name_type: 'zip',
languages: ['en'],
name_order: 'given_first', // EverShop extension: how split names compose the recipient
telephone: { dialCode: '+1' } // EverShop extension: dial code; a pattern comes from a package
};

Core ships one generated record per country (lib/address/formats/, CC-BY 4.0 attribution included) plus a DEFAULT record for unknown codes (%N%n%O%n%A%n%C, require AC). Countries whose native order differs from the Latin one (Japan, China, Korea, Hong Kong, Taiwan …) carry an lfmt; a reader whose language is not the record's native language gets the Latin layout, which is why resolution is keyed on (country, locale).

A package changes a record with patchAddressFormat from bootstrap.ts:

patchAddressFormat('VN', {
fmt: '%N%n%O%n%A%n%D%n%C%n%S',
require: 'ADS',
sublocality_name_type: 'ward',
telephone: { dialCode: '+84', pattern: '^(\\+84|0)[0-9]{9}$', example: '0912 345 678' }
});

All registration calls throw once the address registry is locked, which happens right after every module's bootstrap in each process (web server, build, the event subscriber process that renders emails, cron).

Region providers​

Region data is server-side, hierarchical and read through one registry by the form (options), the display paths (names), the shipping-zone and tax admin pickers and the email.

import { registerRegionProvider, getRegions, resolveRegionName } from '@evershop/evershop/lib/address';

registerRegionProvider('VN', {
levels: ['administrative_area', 'dependent_locality'], // outermost first
list: (parentPath, locale) => parentPath.length === 0 ? provinces : wardsOf(parentPath[0])
});

await getRegions('VN', ['VN-SG']); // active wards of Hồ Chí Minh
await resolveRegionName('VN', 'administrative_area', 'VN-43', 'vi'); // "Bà Rịa - Vũng Tàu" — retired, still named

Core's default provider enumerates one administrative_area level for every country that has ISO 3166-2 subdivisions, plus Google's sub-region keys for Hong Kong and the Cayman Islands. Four rules hold everywhere:

  1. The stored value is the provider's key. A level the provider does not enumerate is free text, and the text is its own key.
  2. Keys are append-only. A refreshed dataset or a package may mark a key retired (hidden from selection, still resolvable) but never remove or rename one. This is what keeps legacy orders, zones and tax rates valid after a data refresh such as Vietnam's 2025 merger, whose 29 absorbed province codes are retired entries pointing at their successors.
  3. Level type follows data. An enumerated level is a select in the form, with dependsOn naming the outer level; anything else is text.
  4. Display name follows the reader. name is shown to readers of the record's language, latinName to everyone else.

Writes accept active keys only; editing a stored address that holds a retired key shows it marked "no longer available" until the customer picks again. See registerRegionProvider.

Resolution​

resolveAddressSchema(country, locale, { surface }) turns the record into the ordered field list the form renders and the server validates:

  1. Pick the layout: lfmt when the reader's language is not the record's native language, else fmt.
  2. Expand each token into fields (%A into the enabled street lines, %N into one or two name fields), the enumerated levels into selects, and append telephone after the name.
  3. Apply the store's address settings (telephone required/optional/hidden, company, line 2 and 3, extra required fields, name format).
  4. Insert the registered extra fields for this country and surface.
  5. Run the addressSchema processor — the hook for a merchant's own rule:
// bootstrap.ts — hide telephone store-wide
addProcessor('addressSchema', (schema) => ({ ...schema, fields: schema.fields.filter((f) => f.id !== 'telephone') }));

The result is cached per (country, locale) and invalidated by every registry change. GraphQL exposes it as addressSchema(country, locale, surface); country may be null, in which case the store's default-country setting decides and the result says which country it resolved.

Validation​

Every address write runs the same pipeline: normalize (trim, upper-case the country, telephone to E.164 with the dial code, compose or split names), then validateAddress against the resolved schema, then the rules extensions added with addAddressValidationRule.

CodeWhen
unknown_fieldA payload key that is neither a shared column nor a registered extra field. Input is never discarded silently.
requiredA field the country or the store requires is empty.
patternPostal code, telephone or an extra field does not match its pattern.
region_invalidA key the provider does not list as active at that level, under those parents.
typeWrong JSON type.
country_not_allowedOutside the sell-to list; for a shipping address also outside every zone.

REST answers 400 with error.errors: [{ field, code, message }], every failing field at once, messages translated with the field label interpolated. The storefront form puts each message on its input. Client-side, the form enforces required and pattern itself from the schema; region keys and cross-field rules are checked on submit.

Formatting and display​

No display surface names an address column. They all print formatted — the lines formatAddress produces from the record's layout with the resolved names, country included:

Chan Tai Man
1 Nathan Road
Tsim Sha Tsui
Kowloon
Hong Kong SAR China

GraphQL returns formatted: [String!]! on every address; the order confirmation email prints {{#each shippingAddress.formatted}}; the admin order view and the storefront summary (AddressSummary) print the same array. A legacy order keeps printing its stored values because retired region keys still resolve to names.

GraphQL​

interface Address {              # CartAddress, CustomerAddress, OrderAddress
recipient: String
givenName: String
familyName: String
organization: String
addressLine1: String
addressLine2: String
addressLine3: String
dependentLocality: Region # { key, name, isoCode }
locality: Region
administrativeArea: Region
postalCode: String
sortingCode: String
country: Country # { code, name }
telephone: String
extra: JSON
formatted: [String!]!
}

type Query {
addressSchema(country: String, locale: String, surface: String): AddressSchema!
regions(country: String!, parentPath: [String!], locale: String): [Region!]!
countries(scope: CountryScope = ALL): [Country!]! # ALL | SELL_TO | SHIPPING
}

Cart.availableShippingMethods takes (country, administrativeArea, locality, dependentLocality, postalCode); ShippingZone.regions lists { country, level, key, name, retired, mergedInto }; the admin-only addressConfigWarnings names zones and tax rates that point at retired keys or at countries the store no longer sells to.

Settings​

Eight rows of the setting table shape every form. The Addresses section of Settings → Customer edits seven of them; the sell-to list sits with the zones it interacts with, above them on Settings → Shipping. POST /api/settings accepts them like any other row. There are no config keys.

SettingValuesDefault
addressNameFormatsingle (one "Full name" field) or split (given + family name)single
addressTelephonerequired, optional, hiddenrequired
addressOrganizationhidden, optional, requiredoptional
addressLine2shown, hiddenshown
addressLine3enabled, disableddisabled
addressRequiredJSON object: postal field → required, to tighten what a country leaves optional{}
addressDefaultCountrystore (the store's country), none, or a country codestore
addressSellToCountriesall or a JSON array of country codesall

A sell-to list of exactly one country is also the default country, whatever the Default country setting says, and the storefront shows the country as a read-only line instead of a select — there is nothing to choose. The same happens on the shipping step when the zones cover a single sold-to country. Switching the name format needs no data migration: a store moving to split still displays every stored recipient, pre-fills a legacy address with a labeled best-effort split when the customer edits it, and stores real parts from then on. The sell-to list is intent: a zone that covers a country outside the list is kept and flagged in the admin, never deleted.

Extra fields​

registerAddressField adds a field that lives in extra and travels with the row through the address book, the cart and the order. Scope it to countries (countries: ['IT']) or to forms (surfaces: ['account']); give it a pattern; it renders through the same renderer map as the built-in fields and validates on both sides.

Integrations​

Payment and carrier code builds its payloads from toIntegrationAddress — resolved names, collected lines, the ISO suffix of the state for consumers that want CA — and keeps its own field names on the way out (CarrierAddress.company, PayPal's admin_area_1, Stripe's state). A country package therefore needs no integration work.

The storefront form​

The storefront renders the schema: country select first, then the country's fields in the record's row order, region levels as lazy selects that load their options when the parent is chosen. The same component serves the address book (surface="account"), the shipping step (shipping, countries from countries(scope: SHIPPING)) and the billing step (billing, SELL_TO). Changing the country clears only the fields that disappeared or changed shape. Themes have four seams — CSS, Areas (addressForm.<surface>, addressField.<id>, addressSummary), the renderer map, and a replacement layout over AddressRendererProps — documented in Address Form and Address Summary.

Legacy orders​

Orders placed before an upgrade or a data refresh must keep printing the same lines. Three things make that hold: the migration renamed columns without rewriting values; region keys are append-only, so a retired key still resolves to its name; and the format layout keeps the tokens legacy rows used (Vietnam's %C district line stays in the layout even though new addresses no longer collect a district). A fixture of pre-migration rows is part of the test suite.

Package recipe​

A country package is data plus two or three bootstrap calls. @evershop/address-vn, released on its own, is the reference:

extensions/address-vn/
├── package.json # "type": "module", peerDependency on @evershop/evershop
├── src/bootstrap.ts # patchAddressFormat + registerRegionProvider
├── src/data/wards.ts # generated: 3,321 wards keyed by official code, grouped by province
└── sources.md # where the data came from, its licence, the key decisions
// src/bootstrap.ts
import { getRegionProvider, patchAddressFormat, registerRegionProvider } from '@evershop/evershop/lib/address';
import { WARDS } from './data/wards.js';

export default () => {
patchAddressFormat('VN', { fmt: '%N%n%O%n%A%n%D%n%C%n%S', require: 'ADS', sublocality_name_type: 'ward',
telephone: { dialCode: '+84', pattern: '^(\\+84|0)[0-9]{9}$', example: '0912 345 678' } });
const provinces = getRegionProvider('VN'); // keep core's keys, add a level below them
registerRegionProvider('VN', {
levels: ['administrative_area', 'dependent_locality'],
list: (path, locale) => path.length === 0 ? provinces.list([], locale) : WARDS[path[0]] ?? []
});
};

Enable it like any extension (system.extensions in config/default.json). Labels come from core's label types (ward → "Ward"), so the package ships no translation file. A package may import only @evershop/evershop/* public paths and its own files; the reference package's own test suite checks that and exercises the patch, the regions, the derived form, the validation and formatted against core's public API.

Upgrading​

This model replaced the fixed full_name / address_1 / address_2 / city / province / postcode form in the breaking address release. Deploy it to a single replica or inside a maintenance window: migrations run inside the new process at boot, so an old pod reading full_name fails as soon as a new pod has renamed the columns. Take a database backup first; scripts/address-formats/rollback-schema.sql plus redeploying the previous release is the documented reverse. Compile fully (npm run compile, not compile:dev — source files were deleted), build, then start. Every open cart re-quotes shipping once after the upgrade.

Database (automatic, metadata-only, no values rewritten)​

OldNew
full_name (all three address tables)recipient
address_1, address_2address_line_1, address_line_2
citylocality
provinceadministrative_area
postcodepostal_code
—new nullable organization, address_line_3, dependent_locality, sorting_code, given_name, family_name, extra jsonb
shipping_zone_province (zone_id, country, province)shipping_zone_region (zone_id, country, level, region_key), unique on the four
tax_rate.province, tax_rate.postcodetax_rate.administrative_area, tax_rate.postal_code

GraphQL​

OldNew
Address.fullName / address1 / address2 / city / province { code name } / postcoderecipient / addressLine1 / addressLine2 / locality { key name } / administrativeArea { key name isoCode } / postalCode; new givenName familyName organization addressLine3 dependentLocality sortingCode extra formatted
type Province, Country.provinces, Query.provinces(countries:)type Region, Country.regions(parentPath), Query.regions(country, parentPath)
Query.countries(countries:), Query.allowedCountries, Setting.allowedCountriesQuery.countries(scope: ALL | SELL_TO | SHIPPING)
CustomerAddress.cartAddressIdCustomerAddress.customerAddressId
ShippingZone.provinces: [Province]ShippingZone.regions: [ZoneRegion!]!
Cart.availableShippingMethods(country, province, postcode)(country, administrativeArea, locality, dependentLocality, postalCode)
TaxRate.province / postcodeadministrativeArea / postalCode
—new Query.addressSchema(country, locale, surface), admin Query.addressConfigWarnings

REST​

Payload keys of addCartAddress, createCustomerAddress, updateCustomerAddress, createMyAddress and updateMyAddress follow the column table; country is the only key the payload schema requires and the rest follows the country's schema (telephone is required by default). Unknown keys answer unknown_field; validation failures answer 400 INVALID_PAYLOAD with error.errors: [{ field, code, message }] instead of a 500 with one joined message. createShippingZone / updateShippingZone take regions: [{ country, level, key }] instead of provinces; tax-rate endpoints take administrative_area / postal_code; createOrder and loadOrderById() carry the renamed address keys.

TypeScript and import paths​

OldNew
@evershop/evershop/types/customerAddress (Address, CustomerAddressGraphql)@evershop/evershop/types/address (Address, AddressGraphql)
@evershop/evershop/lib/locale/countries, …/provincesgetCountries(), getCountryName(), getRegions(), resolveRegionName() from @evershop/evershop/lib/address
validateAddress(address): { valid, errors: string[] }await validateAddress(address, { locale, previous, surface }): { valid, errors: AddressError[] }
addAddressValidationRule({ id, func, errorMessage })addAddressValidationRule({ id, func(address, schema), error: { field?, code, message } })
ShippingContext.origin / destination keys province, city, postcode, address_1administrative_area, locality, postal_code, address_line_1 (+ dependent_locality)
resolveZonesForAddress({ country, province, postcode })({ country, administrativeArea, postalCode })
getAvailableShippingMethods(cartId, country, province, postcode)getAvailableShippingMethods(cartId, destination?)
getTaxRates(taxClassId, country, province, postcode)(taxClassId, country, administrativeArea, postalCode)
CarrierAddressunchanged names; company now filled; new dependentLocality?
orderConfirmationEmailData.shippingAddress.full_name / province_name / …renamed row keys + formatted: string[] and the derived country_name, administrative_area_name, locality_name, dependent_locality_name; province_name is gone — custom templatePath templates must change
Registry key customerDataBeforeUpdate (misused for addresses)customerAddressDataBeforeUpdate; new customerAddressDataBeforeCreate, cartAddressDataBeforeSave, addressSchema
@components/common/locale/*deleted; LANGUAGES → @evershop/evershop/lib/locale/languages

Shipping provider and carrier extensions. Map by token through toIntegrationAddressFromRow instead of reading columns. The same function serves the cart's shipping address, the country/region/postal-code estimate (empty street and city, add your own placeholders) and the store origin:

import type { Address } from '@evershop/evershop/types/address';
import { toIntegrationAddressFromRow } from '@evershop/evershop/lib/address';

export async function toCarrierPayload(a: Address) {
const i = await toIntegrationAddressFromRow(a, 'en');
const area = i.administrativeArea;
return {
// composed from given/family name when the store splits names
name: i.recipient || 'Customer',
company: i.organization,
street1: i.lines[0] ?? '',
street2: i.lines[1],
// the ward / district, where a country has one
street3: [...i.lines.slice(2), i.dependentLocality].filter(Boolean).join(', ') || undefined,
city: i.locality ?? '',
// `US-CA` → `CA`; a name key such as `Kowloon` passes through
state: area ? area.isoSuffix ?? area.key : '',
zip: i.postalCode ?? '',
country: i.country,
phone: i.telephone
};
}

At label time the CarrierAddress core hands you is already mapped: add company and dependentLocality to your payload and keep stripping the ISO prefix from province, which carries the stored region key. The store's weight unit is the weightUnit setting (getSetting('weightUnit', 'kg')), no longer a config key.

Themes​

Area ids customerAddressForm, checkoutShippingAddressForm, checkoutBillingAddressForm became addressForm.account | shipping | billing, each field has addressField.<id>, and addressSummary is unchanged. Forks of addressForm/AddressForm.tsx, NameAndTelephone.tsx, ProvinceAndPostcode.tsx and AddressSummary.jsx are dead files; a fork of addressForm/Index.tsx still wins the alias chain and must adopt AddressRendererProps, otherwise it renders the old field names and fails at submit with unknown_field. The country select is now first and the collected fields change per country.

Events and merchants​

customer_address_* and order_placed payloads carry the renamed row keys. Every open cart re-quotes shipping once; zones or tax rates keyed on a retired region key stop matching new addresses and are flagged in the admin; the eight address settings take their code defaults until the merchant changes them (Settings → Customer → Addresses, and Sell to countries on Settings → Shipping).