Use cart metafields
Cart metafields store custom data on the cart, like delivery instructions or a gift preference. This guide shows you how to read cart metafields into cart state, write them with a route that your app owns, and copy them to the order at checkout.
Anchor to How it worksHow it works
Cart metafields are server-only. The standard actions that Hydrogen's cart forms use don't support them, so metafield changes don't go through the optimistic cart store the way line, discount, note, and attribute changes do. Instead, you combine three pieces:
- Read: A custom
CartFragmenton the cart server handlers adds metafields to cart state. - Write: A route that your app owns calls the
cartMetafieldsSetorcartMetafieldDeletemutation, and returns only the mutation'suserErrors. - Refresh: After a successful write, your component calls
refresh()to read the cart again.
The write and the refresh are separate requests on purpose. If the route saved the metafield and then read the cart in the same request, a failed read would make a successful save look like a failure. With separate requests, a failed refresh shows up as a network error in state.errors.network, and the saved value stays saved.
To learn how cart state and scoped errors work, refer to About the Hydrogen cart.
Anchor to RequirementsRequirements
- Build your cart UI with Hydrogen's cart store. Refer to Build cart UI.
- Know the namespace and key of each cart metafield that you want to use. This guide uses
custom.delivery_instructions. - To copy metafields to orders, create an order metafield definition. Refer to Step 4.
Anchor to Step 1: Read metafieldsStep 1: Read metafields
Select the metafields you need in a fragment named CartFragment, and pass it to createCartServerHandlers():
Cart server handlers
lib/cart-handlers.tsHydrogen adds the fields in your fragment to the default cart fields, and the React and Vue bindings pick up their types. Read a metafield with useCart(), or with cartStore.getState() in vanilla JavaScript. Return a primitive value from the selector, like a string, so the component re-renders only when the value changes:
Read a metafield
components/delivery-instructions.ts
Vanilla JavaScript
import {cartStore} from '../lib/cart';
type Metafield = {namespace: string; key: string; value: string} | null;
function readInstructions() {
const metafields = cartStore.getState().data.metafields as Metafield[] | undefined;
return (
metafields?.find(
(metafield) =>
metafield?.namespace === 'custom' && metafield.key === 'delivery_instructions',
)?.value ?? ''
);
}React
const instructions = useCart(
(state) =>
state.data.metafields?.find(
(metafield) =>
metafield?.namespace === 'custom' && metafield.key === 'delivery_instructions',
)?.value ?? '',
);Vue
const instructions = useCart(
(state) =>
state.data.metafields?.find(
(metafield) =>
metafield?.namespace === 'custom' && metafield.key === 'delivery_instructions',
)?.value ?? '',
);In Vue, useCart() returns a ref, so read instructions.value in your script. The vanilla JavaScript store isn't typed to your fragment, so the example declares the metafield shape.
Anchor to Step 2: Create a metafields routeStep 2: Create a metafields route
Create a POST route at /api/cart/metafields with createShopifyRouteHandler(). Don't extend the /api/cart endpoint. Keep metafield writes in their own route.
The route follows these rules:
- It reads the cart ID on the server.
getCartId()checks the request'scartIdsearch parameter before its cart cookie. The route never accepts anownerIdfrom the request body. - It requires an existing cart. If there's no cart ID, then the route returns a
missing_carterror. Don't callcartCreatein this route. Two writes to an empty cart at the same time would each create a cart, and the one whose cookie is set last would orphan the other cart and drop its write. Ask customers to add a product first. - It deletes one key per request.
cartMetafieldDeletedeletes a single metafield, so the route's delete contract takes a single key. - It returns only
userErrors. The route doesn't read and return the cart. - It reports Storefront API errors. A
200response can carry GraphQLerrorsinstead of the mutation result. The route returns an error response then, so the component shows an error and keeps the customer's draft. - It validates the request body before it calls the Storefront API.
Cart metafields route
lib/cart-metafields.tsThe handler returns one of these result types: {type: 'json', data} for a JSON response, {type: 'error', error: {code, message}, status?} for an error response, which defaults to status 400, or {type: 'redirect', location} for a redirect.
Both mutations take the metafield's namespace and key together as key, like custom.delivery_instructions.
Register the route with handleShopifyRoutes() in your request handler. The handlers array takes groups of handlers, so wrap the route in an object:
Register the metafields route
lib/shopify.tsFor the rest of the request handler, refer to Request handlers.
Anchor to Step 3: Write from a component and refresh the cartStep 3: Write from a component and refresh the cart
Create a function that sends the new value to the metafields route and returns the route's userErrors. A blank value deletes the metafield:
Save delivery instructions
lib/delivery-instructions.tsIn your component, call it when the customer submits the form. When it returns no userErrors, call refresh() to load the saved value into cart state. In React and Vue, refresh() comes from useCartActions(). In vanilla JavaScript, call cartStore.refresh():
Delivery instructions
components/delivery-instructions.ts
Vanilla JavaScript
import {cartStore} from '../lib/cart';
import {saveDeliveryInstructions} from '../lib/delivery-instructions';
// readInstructions() is defined in step 1.
export function mountDeliveryInstructions(container: HTMLElement) {
const id = `delivery-instructions-${crypto.randomUUID()}`;
const form = document.createElement('form');
const label = Object.assign(document.createElement('label'), {
htmlFor: id,
textContent: 'Delivery instructions',
});
const textarea = Object.assign(document.createElement('textarea'), {
id,
value: readInstructions(),
});
const button = Object.assign(document.createElement('button'), {
type: 'submit',
textContent: 'Save instructions',
});
const alert = Object.assign(document.createElement('ul'), {role: 'alert', hidden: true});
form.append(label, textarea, button, alert);
container.replaceChildren(form);
let editing = false;
textarea.addEventListener('input', () => {
editing = true;
});
cartStore.subscribe(() => {
if (!editing) textarea.value = readInstructions();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
button.textContent = 'Saving…';
const userErrors = await saveDeliveryInstructions(textarea.value.trim());
button.disabled = false;
button.textContent = 'Save instructions';
alert.hidden = userErrors.length === 0;
alert.replaceChildren(
...userErrors.map((error) =>
Object.assign(document.createElement('li'), {textContent: error.message}),
),
);
if (userErrors.length > 0) return;
editing = false;
cartStore.refresh();
});
}React
import {useId, useState} from 'react';
import {useCart, useCartActions} from '../lib/cart';
import {saveDeliveryInstructions, type UserError} from '../lib/delivery-instructions';
export function DeliveryInstructions() {
const inputId = useId();
const errorId = useId();
const saved = useCart(
(state) =>
state.data.metafields?.find(
(metafield) =>
metafield?.namespace === 'custom' &&
metafield.key === 'delivery_instructions',
)?.value ?? '',
);
const {refresh} = useCartActions();
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<UserError[]>([]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setErrors([]);
const userErrors = await saveDeliveryInstructions((draft ?? saved).trim());
setSaving(false);
if (userErrors.length > 0) {
setErrors(userErrors);
return;
}
setDraft(null);
refresh();
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={inputId}>Delivery instructions</label>
<textarea
id={inputId}
value={draft ?? saved}
onChange={(event) => setDraft(event.target.value)}
aria-describedby={errors.length > 0 ? errorId : undefined}
aria-invalid={errors.length > 0 || undefined}
/>
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save instructions'}
</button>
{errors.length > 0 ? (
<ul id={errorId} role="alert">
{errors.map((error) => (
<li key={error.message}>{error.message}</li>
))}
</ul>
) : null}
</form>
);
}Vue
<script setup lang="ts">
import {ref, useId} from 'vue';
import {useCart, useCartActions} from '../lib/cart';
import {saveDeliveryInstructions, type UserError} from '../lib/delivery-instructions';
const inputId = useId();
const errorId = useId();
const saved = useCart(
(state) =>
state.data.metafields?.find(
(metafield) =>
metafield?.namespace === 'custom' && metafield.key === 'delivery_instructions',
)?.value ?? '',
);
const {refresh} = useCartActions();
const draft = ref<string | null>(null);
const saving = ref(false);
const errors = ref<UserError[]>([]);
async function handleSubmit() {
saving.value = true;
errors.value = [];
const userErrors = await saveDeliveryInstructions((draft.value ?? saved.value).trim());
saving.value = false;
if (userErrors.length > 0) {
errors.value = userErrors;
return;
}
draft.value = null;
refresh();
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<label :for="inputId">Delivery instructions</label>
<textarea
:id="inputId"
:value="draft ?? saved"
:aria-describedby="errors.length > 0 ? errorId : undefined"
:aria-invalid="errors.length > 0 || undefined"
@input="draft = ($event.target as HTMLTextAreaElement).value"
/>
<button type="submit" :disabled="saving">
{{ saving ? 'Saving…' : 'Save instructions' }}
</button>
<ul v-if="errors.length > 0" :id="errorId" role="alert">
<li v-for="error in errors" :key="error.message">{{ error.message }}</li>
</ul>
</form>
</template>Call refresh() only after metafield writes. Hydrogen's cart forms already keep the store in sync through standard actions, so they don't need it.
If the refresh fails, then the save still succeeded. The failure appears in state.errors.network, and your cart error banner shows it. Don't report it as a failed save.
Anchor to Step 4: Copy metafields to ordersStep 4: Copy metafields to orders
To copy a cart metafield to the order at checkout, the store needs an order metafield definition with the same namespace and key and the cart-to-order copyable capability. Refer to Use metafield capabilities.
Without the definition, writes to the cart still succeed, but Shopify doesn't copy the value to the order.