Reading and Writing Past Order Data with Customer Account UI Extensions: When to Use Signals, GraphQL, and Metafields [Tutorial]

The content of this article was verified in August 2026 with Shopify CLI 3.90 and api_version 2026-04, on a test store with New Customer Accounts enabled and authorization completed. Where a behavior is covered by the official documentation, we link to it; where it is not, we mark it item by item as "verified on a test store as of August 2026."

Introduction: The Three Layers of Data Access

When you display past order information on the account page with Customer Account UI Extensions (UIE), the first thing to decide is how the data gets there. The mechanism differs depending on whether you can read the displayed order in place, need to query Shopify, or want to store and read back values of your own.

Think of the access routes as three layers: signals, the Customer Account API, and metafields.

Layer Suited for Characteristics
Signals (shopify.* globals) Reading the displayed order, line items, and attributes in place Preloaded; no additional requests needed
Customer Account API (GraphQL) Querying Shopify data that signals do not provide Authentication is automatic, the schema can be validated, and you select only the fields you need
Metafields Persisting typed custom data Reading requires prior configuration; writing requires scopes and approval

Inspecting any one of the three layers tells you nothing about what the other layers can provide. There are cases where data absent from the GraphQL schema is readable from a signal, and conversely, cases where a field present in the type definitions never arrives at runtime. Both were verified on a test store, and the former can also be confirmed against the official schema. Because the schema alone cannot tell you whether a value will be returned, check the actual values for signals, GraphQL, and metafields separately. The example of data missing from the schema is covered in section 2; the example of a typed field that does not arrive is covered in section 5.

Our subject is the line item properties saved on past orders by a theme-era account page. We will read them on the new account page and, where needed, go as far as writing the customer's input back to metafields.

1. How to Read Line Item Properties

Account pages built in a theme commonly stored per-order customization information, such as the options a customer chose or the values they entered, in line item properties. When migrating to new customer accounts, the first question is whether this historical data can be read from the new account page.

On a test store, line item properties on past orders were readable from signals. There is no need to migrate the data just to display it. In order-detail targets, you get line items from shopify.lines, the signal for the line item list; in per-line-item targets, from shopify.target. Each line item's attributes holds its properties. The signal locations match the official documentation.

Reading shopify.lines and listing each line item's property keys and values as they are, the minimal form looks like this.

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

export default async () => {
  render(<App />, document.body);
};

function App() {
  const lines = shopify.lines.value;

  return (
    <s-stack>
      {lines.map((line) => (
        <s-stack key={line.id}>
          {line.attributes.map(({key, value}) => (
            <s-text key={key}>{key}: {value}</s-text>
          ))}
        </s-stack>
      ))}
    </s-stack>
  );
}

Keys Starting with an Underscore and What Reading Them Requires

The storefront has a convention of hiding line item properties whose key begins with _ from buyer-facing display. UIE signals do not hide them. In testing, every property was readable, including keys starting with _. No additional access scope request and no GraphQL query is needed; you simply read the preloaded signal.

Internal keys shown as they are mean nothing to the buyer, so when displaying them, pass them through a lookup table that explicitly maps the keys you want to show to their display labels.

In stores where properties were saved through more than one route, several key schemes may coexist. For example, the order form may have saved code-suffixed keys like _option_CODE123 while another route saved named keys like _Option, and the correspondence between the two cannot be absorbed by simple lowercasing. Rather than writing branches on the display side to absorb this, making it explicit as a reverse lookup table also tells whoever reads the code later how the keys came to diverge.

Only keys listed in propertyLabels are replaced with display labels; keys not listed are excluded from display. Internal keys are not in the table, so they never reach the screen.

const propertyLabels = {
  _Option_Color: 'Color',
  _Option_Size: 'Size',
  _Custom_Text: 'Entered text',
};

function visibleProperties(attributes) {
  return attributes
    .filter(({key}) => propertyLabels[key])
    .map(({key, value}) => ({label: propertyLabels[key], value}));
}

Missing Empty-Value Properties and Normalization

If you save a line item property with an empty value, the item itself is missing from the signal. Testing with an order that had 28 properties, the 9 empty-valued items were dropped and only 19 were returned. This behavior is not in the official documentation; it was verified on a test store in August 2026.

Since empty values are dropped, any implementation that maps the length or positions of the attributes array to a form definition will not work. A single empty item shifts every subsequent item forward. Instead, hold the list of required keys as the source of truth and insert a normalization step that treats missing keys as empty.

const expectedKeys = ['_Option_Color', '_Option_Size', '_Custom_Text'];

function normalizeAttributes(attributes) {
  const values = new Map(attributes.map(({key, value}) => [key, value]));
  return Object.fromEntries(
    expectedKeys.map((key) => [key, values.get(key) ?? '']),
  );
}

With expectedKeys as the source of truth, the signal's attributes are converted to a Map, and an object is returned with an empty string for any key not found. Normalize the attributes received from the signal exactly once, before using them for display, initial input values, or generating reorder data. If you handle missing keys at every read site, the same order ends up in a different shape depending on where it is read.

This normalization has a limit, though. The moment you decide that "no key means empty," you lose the distinction between "explicitly saved as empty" and "never entered in the first place." Because empty-valued items are already dropped at the signal level, both look like the same "no key" in the array you receive. If your data needs that distinction, a schema-backed store such as metafields is a better fit than line item properties. Reading and writing metafields is covered in sections 3 and 4.

How Order-Level Attributes Differ

Order-level attributes are read from shopify.attributes. They correspond to note_attributes in the theme era, and the location matches the official documentation. Unlike line item properties, these come back as they are even when the value is empty.

Line item properties and order attributes both arrive under the name "attributes," yet the handling of empty values is asymmetric. This asymmetry is not in the official documentation; it was verified on a test store in August 2026. If you carry the line item rule of "empty values are dropped" over to the order side, items containing an empty string will not be treated as missing, and your reads will diverge. If you read both line items and orders by looking up keys and filling in defaults, one function shape serves both.

Include both an order with saved empty values and an order where the key is absent altogether among your test orders. With only one of the two, you cannot tell whether you are looking at a missing key or an empty value.

2. Differences Between What Signals and GraphQL Provide

Fields Missing from the Customer Account API Order

The Order object in the Customer Account API has neither customAttributes nor any field equivalent to attributes in its schema. What Order has is metafield, metafields, and note. The Admin API Order, by contrast, does have customAttributes.

Build a query the way you would for the Admin API, and specifying the nonexistent field gets rejected by schema validation. The test store returned the same error. Yet the same order-level attributes are available from the shopify.attributes signal.

The data you work with in UIE is a mix of what GraphQL can provide, what only signals provide, and what both provide. Judging something "not available" from the GraphQL schema alone means overlooking data that is present in signals.

The Order for Checking Schema Presence and Actual Values

Split the check into two stages. Before implementing, inspect the schema of the API you will use, drop nonexistent fields from your candidates, and confirm the scopes you need. After implementing, use real orders and compare the values that arrive from signals against those from GraphQL.

The reason for two stages is that what the schema says and what actually arrives can differ. Some data, like this section's customAttributes, is available from a signal despite being absent from the schema; the opposite case, where a field in the type definitions did not arrive in the signal, is covered in section 5.

Use GraphQL as the layer for querying Shopify data that signals do not provide. There is no need to re-fetch via GraphQL attributes that a signal already covers; observe the target's signals first and you avoid adding requests, scopes, and error handling.

3. Reading Metafields: toml Declarations and Access Settings

For app-specific data such as membership tiers, order progress status, or care instructions, the official documentation recommends metafields. Reading them from UIE requires both a declaration on the extension side and an access setting on the metafield definition side; with only one of the two, the value cannot be read.

The first is the extension-side configuration: declare the metafields you read in shopify.extension.toml. The following declaration adds custom.customization_profile to the readable set.

[[extensions.metafields]]
namespace = "custom"
key = "customization_profile"

The second is the metafield-definition-side setting: set the definition's access.customerAccount to READ. The default is NONE, and you can change it with the Admin API's metafieldDefinitionUpdate. It does not affect the admin or storefront access settings.

In testing, we overlooked the access.customerAccount setting on the metafield definition. The toml declared the metafield, yet no value arrived, and we were on the verge of misdiagnosing it as "unreadable due to an API restriction" when we checked the definition side and found it still set to NONE. When a value does not arrive, check in this order: the toml declaration, the definition's namespace and key, access.customerAccount, and then the target owner. That keeps the extension side and the definition side from getting mixed up while you narrow it down.

In order-status targets, declared metafields are preloaded as shopify.appMetafields and can be read without a network round trip. When reading, select the target by namespace, key, and owner, in a form that does not depend on array position.

function CustomizationSummary() {
  const entry = shopify.appMetafields.value.find(
    ({metafield}) =>
      metafield.namespace === 'custom' &&
      metafield.key === 'customization_profile',
  );

  if (!entry) {
    return <s-text>No saved settings.</s-text>;
  }

  const profile = JSON.parse(entry.metafield.value);
  return <s-text>Saved items: {Object.keys(profile).length}</s-text>;
}

This example shows "not saved" when no item matches the namespace and key, and otherwise parses the value as JSON and shows the item count.

In api_version 2026-04, the former checkout metafields have been replaced by order metafields (appMetafields). The steps for carrying over an older implementation are collected in the migration guide.

4. Writing Metafields: Saving Customer Input with metafieldsSet

Writing is also possible directly from UIE. You use the Customer Account API's metafieldsSet mutation, and the owners are Customer, Order, Company, and CompanyLocation. From API 2024-07 onward, it is available in all targets. Authentication is handled by the UIE runtime, so there is no need to embed credentials in your extension code.

Writing requires two permissions: the app must have the customer_read_customers and customer_write_customers access scopes, and it must be approved for protected customer data access. The latter is a requirement common to any app that handles customer data.

As an example, take a form that saves customization information entered by the customer to a customer metafield. On the test store, this form saved successfully.

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';

export default async () => {
  render(<CustomizationForm />, document.body);
};

const mutation = `#graphql
  mutation SaveCustomization($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id namespace key value }
      userErrors { field message code }
    }
  }
`;

function CustomizationForm() {
  const [size, setSize] = useState('');
  const [message, setMessage] = useState('');

  async function save() {
    setMessage('Saving...');

    const response = await fetch(
      'shopify://customer-account/api/2026-04/graphql.json',
      {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
          query: mutation,
          variables: {
            metafields: [{
              ownerId: shopify.customer.value.id,
              namespace: 'custom',
              key: 'customization_profile',
              type: 'json',
              value: JSON.stringify({size}),
            }],
          },
        }),
      },
    );

    const result = await response.json();
    const payload = result.data?.metafieldsSet;
    const errors = [...(result.errors ?? []), ...(payload?.userErrors ?? [])];

    setMessage(errors.length ? errors[0].message : 'Saved.');
  }

  return (
    <s-stack>
      <s-text-field
        label="Size"
        value={size}
        onInput={(event) => setSize(event.currentTarget.value)}
      />
      <s-button onClick={save}>Save</s-button>
      <s-text>{message}</s-text>
    </s-stack>
  );
}

The size received from s-text-field is bundled into JSON and POSTed to shopify://customer-account/api/2026-04/graphql.json to run metafieldsSet. Because ownerId is set to shopify.customer.value.id, the write goes to the logged-in customer.

You cannot tell whether the save succeeded from the HTTP result alone. Even when the HTTP request succeeds, errors may be present in the top-level GraphQL errors or in metafieldsSet.userErrors, so the code above concatenates both before deciding. Validate units, ranges, and required fields before sending, and disable the button while saving to prevent double submission.

Writing to an order metafield uses the same mutation. Change ownerId to the target Order's global ID and the write goes to the order instead.

Because UIE calls the Customer Account API, you can save customer input without standing up a separate backend. For per-customer data such as name readings (furigana), size information, or delivery preferences, reading and writing can be completed within UIE alone. The official tutorial that collects a nickname on the profile page works as a template as it is.

5. Data Behaviors That Tripped Us Up, and How We Handled Them

When a Field in the Type Definitions Does Not Arrive

We wrote a branch that assumed a field present in the type definitions, and the target product was displayed as a regular product. The field we were using was merchandise.product.productType, and the branch that determined the product kind from this value was falling through to the regular-product side.

Looking at the actual signal values in the running target, there were cases where this field was not populated. We later learned that the real cause of the display problem was a separate crash, so we cannot conclude that a mismatch between the types and the runtime was responsible. But at that point we dropped the assumption that a typed field guarantees a value. If you run the same check, try multiple past orders and separate "always missing" from "missing only under certain conditions"; it speeds up the diagnosis.

In case productType does not arrive, do not depend on product type alone; also check for _-prefixed property keys that only the target product has.

function isCustomizable(line) {
  const byProductType = line.merchandise.product?.productType === 'customizable';
  const byProperties = line.attributes.some(({key}) =>
    ['_Option_Color', '_Option_Size', '_Custom_Text'].includes(key),
  );
  return byProductType || byProperties;
}

The check by productType and the check for product-specific property keys sit side by side, and if either holds, the line item is treated as customizable. If one yields no value, the other still stands.

Rendering with Multiple Modals

When we gave each line item block, expanded per unit of quantity, its own <s-modal>, the second and subsequent blocks were not rendered to the DOM. The logic itself ran, and fetches went out once per line item. No errors, no warnings.

If you assume rendering succeeded because the fetches went out, you will not notice, so you have to check directly whether each block exists in the DOM. Temporarily removing the modal made every block appear, which isolated the cause to the modal structure rather than data fetching. The constraint that a single extension can render only one modal is not in the official documentation; it was verified on a test store in August 2026.

Render a single modal for the whole extension, keep which line item is open in state, and switch the content.

function OrderLines({lines}) {
  const [selected, setSelected] = useState(null);

  return (
    <>
      {lines.map((line) => (
        <s-button key={line.id} onClick={() => setSelected(line)}>
          Details for {line.merchandise.title}
        </s-button>
      ))}
      <s-modal heading="Customization details">
        {selected ? <LineDetails line={selected} /> : null}
      </s-modal>
    </>
  );
}

Each line item gets only a button; the pressed line item goes into selected, and the content of the single <s-modal> placed outside the list is switched. If fetches run but the UI does not render, check the placement of the Web Components in addition to the data.

6. Design Guideline: Holding Values as of the Order and Current Values

If values the customer keeps updating, such as customization information, are stored in only one place, the values as of the order that past orders need get mixed up with the current values.

Store Meaning Main use
Order metafield Snapshot as of that order (immutable) Displaying past orders; reordering with the contents from that time
Customer metafield Current latest value Initial values for new orders; per-customer use

If the information lives only in the customer metafield, then after the customer updates a value, reordering a past order produces contents different from the original. Because the point in time itself has business meaning, make an exception to the "single source of truth" principle and hold the snapshot as of the order separately from the current latest value.

For existing past orders, the line item properties act as the snapshot as they are. Give the reading side a prioritized fallback and you can move to the new mechanism without waiting for a bulk migration to finish.

  1. If an order metafield exists, read it
  2. Otherwise, normalize and read the line item properties
  3. If neither exists, show the empty state

Up Next

Once you can read the data, the next step is passing those values into an action. Implementing per-product reordering, which the standard Buy again cannot do, while carrying over the properties read here is covered in Adding a “Reorder Just This Item” Button to Shopify Customer Accounts: Cart Permalinks and Navigation Constraints.

WRITTEN BY

Kitsune

Shopify Developer

I work across design and engineering, building Shopify stores and providing technical support. I have been building e-commerce on Shopify since around 2019 and have worked with more than 100 stores to date. What matters to me is giving shape, through design and development, to the specific requirements that existing apps alone can't reach. I share what I learn on the job.

Talk to us
BACK TO TECH INSIGHTS