Checkout UI Extension Cart Metafields: Reach the Order

A checkout UI extension cannot write an order metafield. It can write a cart metafield, and Shopify copies that onto the order at order creation only if an order metafield definition with the same namespace and key exists and has the cart_to_order_copyable capability enabled. No definition, no copy. The value stays on the cart and disappears with it.

That is the whole answer to “the field saves but the order is empty”. What follows is the mechanism, the code on both sides, and the deprecation that changed the write API underneath it in the 2026-04 version. The extension code targets checkout UI extensions API version 2026-07; the Admin mutation comes from the metafield capabilities guide, which is not versioned.

Why does the value never reach the order?

A checkout runs against a cart, not an order. The order object does not exist until the buyer pays, so there is nothing for the extension to attach data to. Everything the extension stores while the buyer is still deciding lives on the cart: attributes via applyAttributeChange, and metafields via applyMetafieldChange.

Cart metafields do not carry over on their own. The Metafields API reference states the rule plainly: cart metafields are copied to order metafields at order creation time if there is a matching order metafield definition with the cart to order copyable capability enabled.

So the work has two halves in two places. The extension writes the cart metafield. The app, through its config or the Admin API, declares an order metafield definition that is allowed to receive it. Without the second half, the first half never leaves the cart.

The migration guide adds a third requirement that is easy to skip: the app needs the write_orders scope for the copy to happen.

What changed in the 2026-04 API version?

Before 2026-04, a checkout extension had a metafields property and wrote to it with applyMetafieldChange({ type: 'updateMetafield', ... }). The React package wrapped that as useApplyMetafieldsChange() and read values back with useMetafield().

As of 2026-04, all of that is deprecated. The metafields property, the useApplyMetafieldsChange() hook, and the MetafieldUpdateChange and MetafieldRemoveChange inputs are gone from the current path. The replacements are shopify.appMetafields for reading and the updateCartMetafield and removeCartMetafield change types for writing.

The shape of the write changed too, and this is where copied code breaks. The old input put namespace, key, valueType and value at the top level. The new input nests them under a metafield object and replaces valueType with the full metafield type:

// Deprecated as of 2026-04
await shopify.applyMetafieldChange({
  type: "updateMetafield",
  namespace: "my-namespace",
  key: "gift-requested",
  valueType: "string",
  value: "true",
});

// Current
await shopify.applyMetafieldChange({
  type: "updateCartMetafield",
  metafield: {
    namespace: "my-namespace",
    key: "gift-requested",
    value: "true",
    type: "boolean",
  },
});

type here is the metafield definition type: boolean, single_line_text_field, multi_line_text_field and so on. Use the same type on the order definition you create later; the tutorial does, and nothing in the docs promises a conversion between types.

Reading is different as well. shopify.appMetafields is a signal holding every metafield the extension asked for in its TOML, across every owner the extension can see. Each entry carries a target with a type (cart, product, variant, customer, shop, company, companyLocation and shopUser) and an id, so you filter for the cart entry rather than assuming the first match is yours.

How do you write a cart metafield from a checkout UI extension?

Start with the extension TOML. The extension only receives metafields it has declared, and the add a field to checkout tutorial declares them per target:

api_version = "2026-07"

[[extensions]]
name = "gift-message"
handle = "gift-message"
type = "ui_extension"

[[extensions.targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.shipping-option-list.render-after"

[[extensions.targeting.metafields]]
namespace = "custom"
key = "gift_message"

The migration guide shows the same declaration at extension level as [[extensions.metafields]]; either works, the per-target form just keeps the request next to the code that uses it.

Then the extension itself, in the Preact and web components form the tutorial uses. It reads the current value from appMetafields, renders a text area, and writes on blur:

import "@shopify/ui-extensions/preact";
import { render } from "preact";

const NAMESPACE = "custom";
const KEY = "gift_message";

export default function () {
  render(<Extension />, document.body);
}

function Extension() {
  const { applyMetafieldChange, appMetafields, target } = shopify;
  const deliveryGroupList = target.value;

  // The shipping-option-list target renders once per delivery group.
  // Guard, or two copies of this extension overwrite the same key.
  if (!deliveryGroupList || deliveryGroupList.groupType !== "oneTimePurchase") {
    return null;
  }

  const existing = appMetafields.value.find(
    (entry) =>
      entry.target.type === "cart" &&
      entry.metafield.namespace === NAMESPACE &&
      entry.metafield.key === KEY,
  );

  async function save(value) {
    const result = await applyMetafieldChange({
      type: "updateCartMetafield",
      metafield: {
        namespace: NAMESPACE,
        key: KEY,
        type: "multi_line_text_field",
        value,
      },
    });
    if (result.type === "error") {
      console.error(result.message);
    }
  }

  return (
    <s-stack gap="base">
      <s-text-area
        label="Gift message"
        rows={3}
        value={existing?.metafield?.value || ""}
        onBlur={(event) => save(event.target.value)}
      />
    </s-stack>
  );
}

The groupType guard is lifted from the tutorial. The shipping option list target is rendered for one-time purchase and subscription delivery groups separately, and both copies of the extension would otherwise write to the same namespace and key.

Checkout UI extensions are available only to Shopify Plus merchants, so the whole approach is Plus-only. If the store is on a standard plan the extension never loads, and the write_orders and definition work below does not apply.

How do you make the order metafield definition copyable?

This is the half that lives in the app, not the extension. The metafield capabilities guide lists cartToOrderCopyable as an order-only capability and shows both ways to switch it on. Through shopify.app.toml:

[order.metafields.custom.gift_message]
name = "Gift Message"
type = "multi_line_text_field"
capabilities.cart_to_order_copyable = true

Or through Admin GraphQL, which is the right call when the definition has to be created at install time rather than deploy time:

mutation {
  metafieldDefinitionCreate(definition: {
    name: "Gift Message"
    namespace: "custom"
    key: "gift_message"
    type: "multi_line_text_field"
    ownerType: ORDER
    capabilities: { cartToOrderCopyable: { enabled: true } }
  }) {
    createdDefinition {
      id
      capabilities { cartToOrderCopyable { enabled } }
    }
    userErrors { field message code }
  }
}

The guide is explicit that the namespace and key must match between the cart and order metafields. Keep the type identical as well, for the reason above.

On namespaces: the examples above use custom, which is merchant-owned and shows up in the admin as an editable field. The Metafields API reference example uses $app:preferences instead. That is the app-reserved namespace from the metafields overview, which makes the data app-owned and read-only to the merchant. Pick one and use it on both sides. Writing $app:gift from the extension and defining custom.gift_message on the order gives you two different keys and a copy that never fires.

The merchant can also flip this capability by hand. In the admin, an order metafield definition has a “Copy values from cart metafields” option, and the tutorial has the merchant tick it rather than ship it in config. Shipping it in shopify.app.toml or at install means the copy works on every store from the first order, rather than after someone finds the setting.

When does applyMetafieldChange fail?

The call returns a result rather than throwing, so check result.type. Two conditions produce an error by design.

The first is the cart instruction metafields.canSetCartMetafields being false. Cart instructions describe what the current checkout allows the extension to change, and this one is the gate for metafield writes. Read it before rendering the field, and hide the input when it is false rather than showing a box that silently fails.

The second is accelerated checkout. If the buyer is paying with Apple Pay or Google Pay, metafield writes are rejected. The reference offers no workaround. If the data is essential to fulfilment, collect it before checkout, on the product page or in the cart, where the payment method is not yet in play.

What I would do

For any new field that must reach the order, write a cart metafield with updateCartMetafield and ship the order definition with cart_to_order_copyable in shopify.app.toml, so a fresh install gets the definition without an Admin API round trip. Keep namespace, key and type identical on both sides, declare the metafield in the extension TOML so appMetafields can read it back, and hide the input when canSetCartMetafields is false.

If the extension still calls useApplyMetafieldsChange() or writes with type: "updateMetafield", treat the 2026-04 bump as the moment to move it; the data model under those calls is gone from the current version. Our earlier write-up on which checkout UI extension targets run where covers the broader web components move, and the post on shipping metafield definitions in TOML covers the config side in more depth.

I would not use this for data the fulfilment team needs on a standard Shopify plan, because the extension will never load there. And I would not reach for a metafield when nobody will ever filter or report on the value; applyAttributeChange sets a cart attribute with no definition to maintain.

Whoooop builds checkout UI extensions and the apps behind them, including the metafield definitions and Admin API plumbing that make the data usable after the sale. If a checkout customisation keeps losing data between cart and order, our Shopify development work is where we sort that out.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch