Skip to content

feat(cart): add server-only cart metafield intents to /api/cart - #3942

Open
andguy95 wants to merge 4 commits into
previewfrom
an-cart-metafields
Open

feat(cart): add server-only cart metafield intents to /api/cart#3942
andguy95 wants to merge 4 commits into
previewfrom
an-cart-metafields

Conversation

@andguy95

@andguy95 andguy95 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: The preview cart API had no way to set or delete cart metafields. Standard Actions (cart ajax) doesn't support metafields, so this adds server-only metafields-set and metafield-delete intents to /api/cart, plus an example that uses them for buyer delivery instructions that copy onto the order.

Before

POST /api/cart only understood line, discount, and note operations. Setting a cart metafield required writing a custom route with raw Storefront API mutations, including the awkward part: cartMetafieldsSet and cartMetafieldDelete are the only cart mutations whose responses contain no cart object, only userErrors.

After

// set (JSON body shape is structure-inferred, like note/discountCodes/lines)
{ "metafields": [{ "key": "custom.delivery_instructions", "type": "multi_line_text_field", "value": "Leave at back door" }] }

// delete
{ "deleteMetafield": "custom.delivery_instructions" }

Forms are supported too, one metafield per submission: intent=metafields-set with metafieldKey / metafieldType / metafieldValue fields, and intent=metafield-delete with metafieldKey.

What this changes

  • actions.ts: parses the two new intents from JSON and FormData. The parser rebuilds each metafield as exactly {key, type, value} — a client can't smuggle in an ownerId; the server injects it from the resolved cart id.
  • queries.ts: adds the two mutations as standalone constants. Their responses have no cart object, so no cart fragment applies and they live outside makeCartQueries, preserving the invariant that every cartQueries document spreads the cart fragments.
  • server-handlers.ts: after mutating, the handler refetches the cart with queries.cart so every intent returns the same uniform result shape — and the refetch carries the user's custom CartFragment, so the response includes the metafields just written. metafields-set without an existing cart falls back to cartCreate({ metafields }), matching old Hydrogen.
  • Example: new CartDeliveryInstructions component in the cart summary. It posts JSON with fetch (no navigation) and renders the saved value from the POST response. cart-handlers.ts now passes a CartFragment selecting the metafield, demonstrating typed read-back end to end.

Developer impact

  • New exported type: CartMetafieldInput. The CartAction union gains two members.
  • Metafields are server-only: they don't ride the optimistic cart store because Standard Actions has no metafields payload or event. The example documents this and shows the pattern (local state from the POST response).
  • No changeset included yet — this should get a minor changeset for @shopify/hydrogen (additive API surface: new intents and a new exported type).

UX impact

Example only: the cart page and aside get a "Delivery instructions" textarea with save/remove buttons, a disabled-while-saving state, and a role="status" message for saved/error feedback.

Out of scope

  • Other server-only cart operations old Hydrogen supported (attributes, gift cards, buyer identity, delivery addresses/options). Metafields are the only cart mutations returning no cart payload, so the refetch pattern here doesn't need generalizing to those.
  • Optimistic client-store support — blocked on Standard Actions.

Risk

  • Each metafield set/delete costs an extra Storefront API round trip (mutation + cart refetch). Accepted to keep the mutation result shape uniform across intents.
  • The example's cart store keeps a stale copy of metafields after a save; each mounted instance (page and aside) tracks its own saves until the store's next revalidation.

How to Test

  1. Run pnpm install && pnpm --filter @shopify/hydrogen build to rebuild the package.
  2. Create local env files for the example (examples/hydrogen/.env) pointing at a store, and start the example hydrogen dev server.
  3. Open http://localhost:5173, add a product to the cart, and open /cart.
  4. Under Totals, enter delivery instructions and select Save instructions. Confirm "Saved." appears without a page reload.
  5. Reload the page. Confirm the saved instructions render from cart data.
  6. Select Remove instructions. Confirm the value clears and the remove button disappears.
  7. To verify order copy: the store needs an order metafield definition for custom.delivery_instructions with the cart-to-order copyable capability enabled. Place a test order and check the order's metafields in admin.

@andguy95 andguy95 added gsd:50917 New Hydrogen preview labels Aug 11, 2026
@andguy95 andguy95 changed the title An cart metafields feat(cart): add server-only cart metafield intents to /api/cart Aug 11, 2026
@andguy95
andguy95 marked this pull request as ready for review August 11, 2026 23:52
@andguy95
andguy95 requested a review from a team as a code owner August 11, 2026 23:52
variables: { metafields: metafields.map((metafield) => ({ ...metafield, ownerId: cartId })) },
});
const { userErrors } = assertMutationData(result, "cartMetafieldsSet");
return refetchCartAfterMetafieldMutation(cartId, userErrors, storefront, queries);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refetching cart after mutation because metafield mutations dont return cart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the whole cart back from that mutation? Maybe that's required by our contract or something else?

}

// Intents that fall back to cartCreate when no cart exists yet.
const CART_CREATING_INTENTS: ReadonlySet<CartAction["intent"]> = new Set(["add", "metafields-set"]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied over parity where metafield set can create a cart if one doesn't exist.

Was debating if this was needed. But then figured this would make sense if storefronts have custom product budle meta, or quiz preference meta before the added a product that needs to get attached to cart -> order first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good insight 👍

@frandiox frandiox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this! I've left a few comments below but this also makes me think about bigger directions:

JSON API

Thinking that if we ever land this in Standard Actions, we might want to have a consistent API with that we have already there. What would you think about this instead?

updateCart({
  note: "",
  discountCodes: ["SAVE10"],
  metafields: {
    set: [
      {
        key: "custom.instructions",
        type: "single_line_text_field",
        value: "Back door",
      },
    ],
    delete: ["custom.old_value"],
  },
});

So basically in Hydrogen's server-only support for metafields we'd do:

// Set metafields
{metafields: {set: [{...}]}}
// Delete metafields
{metafields: {delete: ["..."]}}

I know the delete mutation is only for 1 at a time, but it's actually possible to add multiple mutations in the same request by using graphql aliases... so perhaps the array is a good feature to keep it consistent with other fields?

Local state

I see this relies on useState etc. for metafields, which makes sense since it's custom data at this point. I was thinking that perhaps we could offer a place for custom data within the cart store itself? It could be an "easy" win because it would work in every framework.

The main thing we need is:

  • A place to store it: maybe under store.data.custom = {...} or similar.
  • A way to update it: "official" data relies on standard events. This could have its own const {setCustomData} = useCart() function or similar? Just as an example.

Would this make sense at all?

CartAction,
CartLineAddInput,
CartLineUpdateInput,
CartMetafieldInput,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also export CartMetafieldInput from core/index.ts? Or not needed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I’ve exported CartMetafieldInput now

storefront: CartMutationClient,
queries: RuntimeCartQueries,
): Promise<MutationResult> {
const result = await storefront.graphql(queries.cart, { variables: { id: cartId } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metafield write can succeed, but the cart query after it can still fail. In that case, the API rejects the whole request and the UI says "Network error" even though the metafield was saved. Not sure what would be the best action at that point... perhaps return partial data? But that begs the question... do we need to return the whole cart (which I already asked in another comment lol).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good catch, didn't even cross my mind to think the second call might fail 🥴.

We don’t have to return the cart. I added the refetch to keep metafield mutation results consistent with the existing cart handlers.

Not refetching better matches SFAPI behavior and avoids an additional requests/failure cases.

Keeping it would be an intentional developer-ergonomics choice with a consistent response shape at the cost of that extra request. Curious what @fredericoo thinks!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could leave metafields out of the handled API and just force users to write their own logic? No sure how often are these used tbh

@fredericoo fredericoo Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the more we talk about this feature, the more i think this (cart metafields) can be done post GA or even as a skill only

the reason why is that developers can still write it themselves without any of this with not a lot of boilerplate. What i would do with this task is ensure:

  1. we are able to pass other selected fields into the cart store (i think we already are)
  2. we can tap into the POST to add an intent ourselves (using a custom route with the cart server handler, but branching off if the intent is different)
  3. alternatively we can revalidate the cart store manually. E.g.: a nextjs server function gets called to set the metafield, once done we revalidate the cart (client side request)

if/when those 3 are possible, i'd even go as far as saying it's a good example to show how to extend our own APIs. then we ship zero metafield code that caters to very specific edge cases

sorry if this does not directly answer the question, its just from what we've discussed yesterday i get this feeling

maybe we can gauge how many customers use this, and try to see if we can whip it easily with just skills first?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can tap into the POST to add an intent ourselves (using a custom route with the cart server handler, but branching off if the intent is different)

Not sure if we should open up the "intents". It's something that might change perhaps if we eventually support multi-action POSTs? (e.g. updating note + lines at the same time, like SA do).


import { useCart } from "~/lib/cart";

// Cart metafields are server-only: the Storefront cart ajax API (Standard

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theoretically... we could still add them to the cart store even if they are not part of standard actions, right?
And wouldn't it be automatic if they add a custom cart fragment with metafields?

I guess the problem is that they can't be updated without standard events after mutations?

Comment thread examples/hydrogen/app/components/CartDeliveryInstructions.tsx Outdated
sellingPlanId?: string;
};

export type CartMetafieldInput = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this type reuse the generated CartInputMetafieldInput?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. CartMetafieldInput now aliases the generated CartInputMetafieldInput.

}

// Intents that fall back to cartCreate when no cart exists yet.
const CART_CREATING_INTENTS: ReadonlySet<CartAction["intent"]> = new Set(["add", "metafields-set"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good insight 👍

variables: { metafields: metafields.map((metafield) => ({ ...metafield, ownerId: cartId })) },
});
const { userErrors } = assertMutationData(result, "cartMetafieldsSet");
return refetchCartAfterMetafieldMutation(cartId, userErrors, storefront, queries);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the whole cart back from that mutation? Maybe that's required by our contract or something else?

The PR adds new public API surface (metafields-set / metafield-delete
intents and the CartMetafieldInput type), which is additive for
consumers and therefore a minor bump for @Shopify/hydrogen.
@andguy95
andguy95 force-pushed the an-cart-metafields branch from 6fdfd8d to 2cff079 Compare August 13, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants