Customer Addresses
Goal
Build an address book: list the customer's addresses, add one, edit one, delete one, and pick the default billing and shipping address. The important part is that not one write method on useAddress updates the shared list — reloading it after every change is the pattern, not an optimisation you skipped.
Shopware Flow
listAddress post /account/list-address is a criteria search scoped to the customer that the sw-context-token resolves to. It is the only operation here that fills customerAddresses, and useAddress keeps that list in the swCustomerAddresses injection.
The five write operations return either the one address they touched or nothing at all. None of them returns the list, and useAddress makes no attempt to patch it locally. So the shape of every handler on this page is the same: write, then loadCustomerAddresses().
Step 1
UI: Open the address book
The page loads the list once. useCountries and useSalutations fetch their own lists on mount, so a form can be rendered without extra plumbing.
- Code
await loadCustomerAddresses()- State
- swCustomerAddresses
- Types
- listAddress response
Read the diagram from left to right:
- The page calls
loadCustomerAddresses()once to fill the shared list. listAddress post /account/list-addressreturns the addresses inelements.- A form submit calls
createCustomerAddress(address)orupdateCustomerAddress(address). - The Store API answers with that single address, not with the list.
- The handler calls
loadCustomerAddresses()again, because nothing else will. setDefaultCustomerBillingAddress(id)andsetDefaultCustomerShippingAddress(id)are bodyless PATCH requests that need the same reload.
Editing an address book entry needs nothing beyond that reload. Changing a default needs one more call, because the defaults live on the customer rather than on the address: refreshSessionContext(). It is the refresh that covers both consumers — see State And Session.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load the addresses | loadCustomerAddresses() | POST /account/list-address | listAddress response |
| Load the countries | fetchCountries() | POST /country | readCountry response |
| Load the salutations | fetchSalutations() | POST /salutation | readSalutation response |
| Create an address | createCustomerAddress(address) | POST /account/address | createCustomerAddress body |
| Update an address | updateCustomerAddress(address) | PATCH /account/address/{addressId} | updateCustomerAddress body |
| Delete an address | deleteCustomerAddress(addressId) | DELETE /account/address/{addressId} | none — the operation answers 204 No Content |
| Set the default billing | setDefaultCustomerBillingAddress(addressId) | PATCH /account/address/default-billing/{addressId} | none — the operation answers 200 with an empty body |
| Set the default shipping | setDefaultCustomerShippingAddress(addressId) | PATCH /account/address/default-shipping/{addressId} | none — the operation answers 200 with an empty body |
The two default rows send no body. The address id is the whole request, which is why they cannot fail on validation — only on ownership. Neither answers with a body either, so the Promise<string> that useAddress declares for them resolves to nothing usable — call them for the effect, not for the return value.
fetchCountries and fetchSalutations are the only rows you do not normally call yourself: both composables fetch on mount when their shared list is still empty. The methods are there for the cases where you need to refetch or to seed the list before mount.
Composables
useAddress: the address book. ReadscustomerAddresses, loads it withloadCustomerAddresses, writes withcreateCustomerAddress,updateCustomerAddress,deleteCustomerAddress,setDefaultCustomerBillingAddressandsetDefaultCustomerShippingAddress, and offerserrorMessageBuilder(error)for one specific violation code.useCountries:getCountriesandgetCountriesOptionsfor the country select, andgetStatesForCountry(countryId)for the dependent state select. It fetches on mount with astatesassociation already merged in, so the states are present without a second request.useSalutations:getSalutationsfor the salutation select, since the address takes asalutationId.useUser:defaultBillingAddressIdanddefaultShippingAddressIdto decide which entry in the list is a default. They readuser.defaultBillingAddressId, which every Shopware version returns.
Types
Use generated Store API types when you need to type the address form, the list response, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type AddressListResponse =
operations["listAddress post /account/list-address"]["response"];
type CustomerAddressBody = Schemas["CustomerAddressBody"];
type CustomerAddress = Schemas["CustomerAddress"];
type Country = Schemas["Country"];
type CountryState = Schemas["CountryState"];The two address types are not the same and the difference matters. CustomerAddressBody is what the operations accept and requires countryId, firstName, lastName, city and street. CustomerAddress is what comes back and additionally carries id and customerId. It also declares isDefaultBillingAddress and isDefaultShippingAddress, but those two are optional runtime fields that only exist from Shopware 6.7.7.0 — read the Edge Cases before you branch on them.
Minimal Vue Example
<script setup lang="ts">
import type { Schemas } from "#shopware";
const {
customerAddresses,
loadCustomerAddresses,
createCustomerAddress,
updateCustomerAddress,
deleteCustomerAddress,
setDefaultCustomerBillingAddress,
setDefaultCustomerShippingAddress,
} = useAddress();
const { getCountries, getStatesForCountry } = useCountries();
const { getSalutations } = useSalutations();
const { refreshSessionContext } = useSessionContext();
// the ids work on every Shopware version — see Edge Cases for why not the address flags
const { defaultBillingAddressId, defaultShippingAddressId } = useUser();
const emptyAddress = () => ({
salutationId: "",
firstName: "",
lastName: "",
street: "",
zipcode: "",
city: "",
countryId: "",
countryStateId: "",
});
const form = reactive(emptyAddress());
const editedId = ref("");
const pendingId = ref("");
const isSaving = ref(false);
const addressError = ref("");
const states = computed(() =>
form.countryId ? (getStatesForCountry(form.countryId) ?? []) : []
);
// an empty string is not a valid UUID, so drop the optional ids left unset.
// the body type is what the operation really accepts; the cast only satisfies
// the stricter signature useAddress declares.
const toRequestBody = () => {
const { salutationId, countryStateId, ...required } = form;
const body: Schemas["CustomerAddressBody"] = {
...required,
...(salutationId ? { salutationId } : {}),
...(countryStateId ? { countryStateId } : {}),
};
return body as Schemas["CustomerAddress"];
};
const selectCountry = (countryId: string) => {
form.countryId = countryId;
// a state id from the previous country is not valid for the new one
form.countryStateId = "";
};
onMounted(async () => {
try {
await loadCustomerAddresses();
} catch {
addressError.value = "Your addresses could not be loaded.";
}
});
const startEdit = (address: Schemas["CustomerAddress"]) => {
editedId.value = address.id;
Object.assign(form, {
salutationId: address.salutationId ?? "",
firstName: address.firstName,
lastName: address.lastName,
street: address.street,
zipcode: address.zipcode ?? "",
city: address.city,
countryId: address.countryId,
countryStateId: address.countryStateId ?? "",
});
};
const resetForm = () => {
editedId.value = "";
Object.assign(form, emptyAddress());
};
const save = async () => {
addressError.value = "";
isSaving.value = true;
try {
if (editedId.value) {
await updateCustomerAddress({
...toRequestBody(),
id: editedId.value,
});
} else {
await createCustomerAddress(toRequestBody());
}
// no write method updates the shared list
await loadCustomerAddresses();
resetForm();
} catch {
addressError.value = "The address could not be saved.";
} finally {
isSaving.value = false;
}
};
const runOnAddress = async (
addressId: string,
action: () => Promise<unknown>,
message: string,
refreshContext = false
) => {
addressError.value = "";
pendingId.value = addressId;
try {
await action();
await loadCustomerAddresses();
if (refreshContext) await refreshSessionContext();
} catch {
addressError.value = message;
} finally {
pendingId.value = "";
}
};
const remove = (addressId: string) =>
runOnAddress(
addressId,
() => deleteCustomerAddress(addressId),
"This address could not be deleted."
);
const makeDefaultBilling = (addressId: string) =>
runOnAddress(
addressId,
() => setDefaultCustomerBillingAddress(addressId),
"The default billing address could not be changed.",
true
);
const makeDefaultShipping = (addressId: string) =>
runOnAddress(
addressId,
() => setDefaultCustomerShippingAddress(addressId),
"The default shipping address could not be changed.",
true
);
</script>
<template>
<p v-if="addressError">{{ addressError }}</p>
<p v-if="!customerAddresses.length">No addresses to display (the list is also empty before the first load resolves).</p>
<ul v-else>
<li v-for="address in customerAddresses" :key="address.id">
<p>
{{ address.firstName }} {{ address.lastName }}, {{ address.street }},
{{ address.zipcode }} {{ address.city }}
</p>
<p v-if="address.id === defaultBillingAddressId">
Default billing address
</p>
<p v-if="address.id === defaultShippingAddressId">
Default shipping address
</p>
<button
type="button"
:disabled="pendingId === address.id"
@click="startEdit(address)"
>
Edit
</button>
<button
v-if="address.id !== defaultBillingAddressId"
type="button"
:disabled="pendingId === address.id"
@click="makeDefaultBilling(address.id)"
>
Use for billing
</button>
<button
v-if="address.id !== defaultShippingAddressId"
type="button"
:disabled="pendingId === address.id"
@click="makeDefaultShipping(address.id)"
>
Use for shipping
</button>
<button
v-if="
address.id !== defaultBillingAddressId &&
address.id !== defaultShippingAddressId
"
type="button"
:disabled="pendingId === address.id"
@click="remove(address.id)"
>
Delete
</button>
</li>
</ul>
<form @submit.prevent="save">
<h2>{{ editedId ? "Edit this address" : "Add an address" }}</h2>
<label>
Salutation
<select v-model="form.salutationId">
<option value="">Not specified</option>
<option
v-for="salutation in getSalutations"
:key="salutation.id"
:value="salutation.id"
>
{{ salutation.translated?.displayName ?? salutation.displayName }}
</option>
</select>
</label>
<label>
First name
<input v-model="form.firstName" type="text" autocomplete="given-name" />
</label>
<label>
Last name
<input v-model="form.lastName" type="text" autocomplete="family-name" />
</label>
<label>
Street
<input v-model="form.street" type="text" autocomplete="street-address" />
</label>
<label>
Postal code
<input v-model="form.zipcode" type="text" autocomplete="postal-code" />
</label>
<label>
City
<input v-model="form.city" type="text" autocomplete="address-level2" />
</label>
<label>
Country
<select
:value="form.countryId"
@change="selectCountry(($event.target as HTMLSelectElement).value)"
>
<option value="">Select a country</option>
<option
v-for="country in getCountries"
:key="country.id"
:value="country.id"
>
{{ country.translated.name }}
</option>
</select>
</label>
<label v-if="states.length">
State
<select v-model="form.countryStateId">
<option value="">Not specified</option>
<option v-for="state in states" :key="state.id" :value="state.id">
{{ state.translated?.name ?? state.name }}
</option>
</select>
</label>
<button type="submit" :disabled="isSaving">
{{ isSaving ? "Saving…" : editedId ? "Save changes" : "Add the address" }}
</button>
<button v-if="editedId" type="button" @click="resetForm()">Cancel</button>
</form>
</template>State And Session
The list lives in the swCustomerAddresses injection, so an address selector in the checkout and the address book on the account page read the same array. It starts as an empty array rather than undefined, which means "no addresses" and "not loaded yet" look identical — track the loading state yourself if that distinction matters.
useUser().defaultBillingAddressId needs no loading call of its own. A Nuxt storefront refreshes the session context once at app start, and useUser derives its state from that context, so the default ids are already there when the page mounts.
Ownership is entirely session-based. Every operation resolves the customer from the sw-context-token; there is no customer id in any path or body. A guest or a logged-out visitor gets a 403, and loadCustomerAddresses handles that one status specially: it clears the shared list and then rethrows, so the UI empties and the error surfaces.
Changing a default also changes the customer, so reloading the address list is not enough — that is the one case on this page that needs a second call. Use refreshSessionContext(), not refreshUser():
refreshSessionContext()callsreadContext get /context, whose response carries the wholecustomer.useUsermirrors that customer into its own state withsyncRefs, so this one call updatesuseSessionContext().activeBillingAddressanduseUser().defaultBillingAddressId.refreshUser()callsreadCustomer post /account/customerand writes onlyuseUser's state. The sales channel context stays stale, and the checkout reads its active addresses from that context.
Edge Cases
- No write method updates
customerAddresses. Create, update, delete and both default switches all needloadCustomerAddresses()afterwards. - The declared type of
loadCustomerAddresses()takes no arguments, while the implementation accepts aCriteria. Passing one is a type error even though the request would carry it. createCustomerAddressandupdateCustomerAddressare typed withSchemas["CustomerAddress"], which requiresidandcustomerId, but the operations acceptCustomerAddressBody, which has neither. Creating an address therefore means satisfying a type stricter than the request — a cast, not a real requirement.updateCustomerAddressreads the address id from the object and puts it in the path. An object withoutidproduces a request against/account/address/undefined.countryId,firstName,lastName,cityandstreetare required.zipcodeis not, because not every country has one.countryStateIdis only valid for countries that have states.getStatesForCountryreturnsnull— not an empty array — for those that do not.- An empty string is not a valid id. A form that seeds
salutationIdandcountryStateIdwith""has to strip them before the request, or the server rejects the write on a field the customer never filled in. - Switching the country does not invalidate a
countryStateIdthat was picked for the previous one. Clear it in the country change handler; nothing inuseCountriesdoes it for you. CustomerAddressdoes carryisDefaultBillingAddressandisDefaultShippingAddress, but both are optional runtime fields added in Shopware 6.7.7.0. Against an older instance they come backundefined, which reads as "not a default" for every entry.useUser().defaultBillingAddressIdcompared againstaddress.idhas no version floor, which is why the example and the starter template both use it.setDefaultCustomerBillingAddressandsetDefaultCustomerShippingAddressare declaredPromise<string>, but both operations answer with an empty body. The resolved value is not a usable id.- Deleting the default billing or shipping address is rejected by the server. Hide the button for those entries rather than explaining the failure afterwards.
errorMessageBuilderhandles onlyVIOLATION::IS_BLANK_ERRORand returnsnullfor everything else. Treatnullas "not a message I can build", not as "no error".- A
403fromloadCustomerAddressesboth empties the list and throws. Catching the error without rendering an empty state gives the customer a blank page.
Common Mistakes
- Do not assume a successful write refreshed the list.
- Do not patch
customerAddresseslocally after a write. It is aComputedRefover the shared value. - Do not skip the context refresh after changing a default. The checkout reads the active addresses from the context.
- Do not reach for
refreshUser()to pick up a new default. It leaves the sales channel context stale;refreshSessionContext()updates both. - Do not treat an empty
customerAddressesas proof that the customer has no addresses. - Do not send a
countryStateIdfor a country without states, and do not send it as an empty string either. - Do not read
address.isDefaultBillingAddressunless you require Shopware 6.7.7.0 or newer. - Do not offer to delete an address that is a current default.
- Do not render
errorMessageBuilder(error)without a fallback for itsnullresult. - Do not build the update path yourself.
updateCustomerAddressderives it from the address id.
Testing Checklist
- Opening the page calls
listAddress post /account/list-addressonce and fillscustomerAddresses. - Creating an address calls
createCustomerAddress post /account/addressand then reloads the list. - The new entry appears only after the reload resolves.
- Updating an address sends the id in the path and reloads the list.
- Deleting an address calls the delete operation and removes the entry from the rendered list.
- Setting a default calls the matching bodyless PATCH, reloads the list, and refreshes the session context.
- After that refresh, both
useSessionContext().activeBillingAddressanduseUser().defaultBillingAddressIdreflect the new default. defaultBillingAddressIdanddefaultShippingAddressIddrive which buttons render on each entry.- Submitting the form without a country is rejected and shown on that field.
- Selecting a country without states hides the state select and sends no
countryStateId. - Switching from a country with states to one without clears the previously selected state.
- A logged-out visitor sees an empty state and an error rather than a stale list.