Customizing the Full Cart Page in Hyva Theme, Not the Minicart
AI generated
Hyvä
phtml
Hyva Theme · Cart Page
Customizing the Full Cart Page in Hyva Theme
Cart page template, Alpine interactions, and coupon UX beyond the minicart

The minicart covers a quick glance at the cart, but the actual cart page is a separate template with its own requirements. Quantity changes without a reload, a well thought out cross-sell area, and a coupon input that communicates errors clearly often decide between an abandoned cart and a started checkout.

14 min read Cart Page Template updateCartItems Coupon UX Cross-Sell GraphQL Mutations

1. The difference between the minicart and the cart page template

The minicart is a dropdown fragment in the header fed by the global cart store from Hyva's GraphQL module, primarily meant for a quick overview. The cart page, on the other hand, is a standalone route under checkout/cart with its own layout handle checkout_cart_index and its own template, offering far more room for product images, item options, shipping estimate widgets, and a cross-sell area.

In practice, the minicart and the cart page share the same Alpine cart store and the same GraphQL mutations, but not the same template. Anyone trying to build the cart page as simply an enlarged minicart wastes the extra space available for information that makes sense on a standalone page but would clutter a header dropdown, such as detailed item options or a gift wrap selection.

2. The template structure of the cart page in Hyva

The relevant files live in the Hyva checkout module under Magento_Checkout/templates/cart, with the actual skeleton controlled through checkout_cart_index.xml as the layout handle. Unlike Luma, there is no Knockout container with dynamically loaded UI components, but a server rendered phtml skeleton that loads its initial state from a GraphQL query on page load and afterward gets updated exclusively through Alpine and further GraphQL mutations.

For customizations, overriding the existing template is preferable to rewriting it from scratch, because Hyva already provides a clean structure for the basic cart item iteration, the summary box, and the coupon section. Custom additions such as a cross-sell area can be hooked in as an extra block through layout XML without tearing apart the existing structure.


<!-- app/design/frontend/Mironsoft/default/Magento_Checkout/layout/checkout_cart_index.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="Magento\Framework\View\Element\Template"
                   name="cart.crosssell"
                   template="Magento_Checkout::cart/crosssell.phtml"
                   after="checkout.cart.form" />
        </referenceContainer>
    </body>
</page>

3. Quantity changes as an Alpine interaction without a page reload

A full page reload on every quantity change was the norm in Luma and is a clear UX step backward in the Hyva era. Instead, the quantity field gets bound to an Alpine method that triggers the updateCartItems mutation after a short delay, known as debouncing, and afterward re-renders only the affected areas: the line total, the grand total, and the minicart counter.

A clear loading state during the request matters, otherwise an impatient customer might click multiple times, firing off several overlapping requests whose responses could arrive out of order. A simple per-row x-data flag that disables the input area during the request reliably prevents this race condition.


function cartItem(itemUid, initialQty) {
  return {
    qty: initialQty,
    updating: false,
    debounceTimer: null,

    onQtyChange() {
      clearTimeout(this.debounceTimer);
      this.debounceTimer = setTimeout(() => this.updateQty(), 500);
    },

    async updateQty() {
      this.updating = true;
      try {
        await this.$store.cart.updateItems([
          { cart_item_uid: itemUid, quantity: this.qty },
        ]);
      } catch (error) {
        this.$store.cart.showError('Could not update quantity.');
      } finally {
        this.updating = false;
      }
    },
  };
}

4. Removing items without a full page refresh

Removing an item follows the same pattern as a quantity change, with one difference: immediate visual feedback matters more here than for a plain quantity update. A short fade-out transition before the item actually leaves the DOM clearly signals to the customer that the action was registered, even before the GraphQL response comes back at all.

When the cart is empty after the last removal, the cart page needs to switch to its own empty state view instead of showing an empty table. This state is cleanest when derived reactively from the item count in the cart store instead of managed separately, so it stays correct automatically regardless of whether the last item was removed from the cart page or from the minicart.

5. Coupon input UX without frustration points

The coupon input is one of the spots in the checkout funnel where poor error communication costs a disproportionate amount of trust. An expired or mistyped code should not surface as a generic error message, it should, as far as the GraphQL response allows, name the concrete reason, for example expired, not combinable with other promotions, or minimum order value not reached.

A visible loading state during validation matters just as much, since applyCouponToCart triggers a full server side recalculation of the order total including tax and shipping, and can therefore take noticeably longer than a plain quantity update. A disabled button with a loading icon during the request prevents duplicate submissions of the same code.


<div x-data="{ code: '', applying: false, error: '' }" class="flex flex-col gap-2">
  <div class="flex gap-2">
    <input type="text" x-model="code" placeholder="Coupon code" class="border p-2">
    <button
      type="button"
      x-bind:disabled="applying || code.length === 0"
      x-on:click="applying = true; applyCoupon(code)
        .catch(e => error = e.message)
        .finally(() => applying = false)"
    >
      <span x-show="!applying">Apply</span>
      <span x-show="applying">Checking...</span>
    </button>
  </div>
  <p class="text-sm text-red-600" x-show="error" x-text="error"></p>
</div>

6. The cross-sell area on the cart page

Magento's classic cross-sell attribute, managed in the backend under Related Products, is traditionally displayed right on the cart page below the item list and is one of the few product recommendations that genuinely tie into the purchase decision, since the customer is already inside the buying process. The cart GraphQL query with its items field and nested cross_sell_products delivers matching suggestions right alongside the rest of the cart state.

Placement matters a lot for conversion: the cross-sell area belongs below the actual item list and summary, never above the proceed to checkout button, otherwise it reads as an extra obstacle instead of an optional add-on. A simple add button per cross-sell product that reuses the same addProductsToCart mutation as the rest of the page keeps the implementation consistent.

7. Error handling and optimistic UI

Between loading the cart page and actually submitting a quantity change, stock levels can have shifted, especially for high demand products with only a few units left. In that case, the updateCartItems response contains user_errors with a concrete error message that should be shown right at the affected row, not as a generic alert at the top of the page.

An optimistic UI that shows the new quantity immediately, before the server response arrives, noticeably improves perceived speed, but carries the risk of briefly showing a wrong state on error. The pragmatic middle ground is a visible but subtle loading state instead of true optimistic UI, which reduces perceived wait time without taking on the risk of incorrect intermediate states.

8. Performance considerations for the cart page

The cart page is inherently personalized and therefore not a sensible candidate for full page caching, but the basic HTML skeleton can still be cached as long as the actual content is fully loaded through GraphQL. It matters to implement debouncing on quantity changes consistently, since every keystroke without a delay would otherwise trigger its own GraphQL request and fire off unnecessarily many parallel requests during fast typing.

The cross-sell area should also ship its product images with loading lazy, since it usually only becomes visible below the initial viewport. For carts with many line items, it is also worth loading the cross-sell query only after the initial item list has rendered, instead of bundling it into the same GraphQL request as the cart content itself, to shorten the time to the first visible item list.

9. Common mistakes when customizing the cart page

A common mistake is implementing the minicart and cart page as completely separate systems, even though both should use the same cart store. This leads to inconsistencies, for example when a quantity change on the cart page does not immediately show up in the minicart counter in the header, because two independent states are being maintained that never sync with each other.

Just as problematic is missing debouncing on quantity fields, causing every keystroke to trigger a GraphQL mutation immediately and burdening the server with unnecessary requests. And finally, the cart page's empty state view often gets forgotten or only partially built, so customers see an empty but structurally still present table after removing the last item, instead of a clear prompt to continue shopping.

Area Minicart Cart Page Shared Foundation
Data source Alpine cart store Alpine cart store GraphQL cart query
Layout handle default (header fragment) checkout_cart_index no overlap
Quantity change Limited or none Full featured with debouncing updateCartItems mutation
Coupon input Rarely useful Central UX building block applyCouponToCart mutation
Cross-sell Not provided for Below the item list cross_sell_products field

Mironsoft

Hyvä theme development and Luma migration

Still running Luma, or a Hyvä theme that just doesn't feel right?

We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.

Luma-to-Hyvä Migration

Move an existing shop to Hyvä in a structured way, without losing functionality.

Custom Theme Development

Build a custom Hyvä theme from scratch based on your design.

Performance Optimization

Improve Core Web Vitals and load times in the Hyvä frontend with purpose.

10. Summary

Cart Page in Hyva: Key Takeaways

Standalone template

The cart page is more than an enlarged minicart, with its own layout handle and room for detail.

No reload needed

Quantity changes and removal run through GraphQL mutations and only update affected areas.

Name coupon errors specifically

Generic error messages for an invalid code cost unnecessary trust in the checkout funnel.

Shared cart store

Minicart and cart page must use the same state, otherwise counters and content drift apart.

11. FAQ: Cart Page in Hyva: Key Takeaways

1Is the cart page in Hyva just an enlarged minicart?
No, it is a standalone template with its own layout handle checkout_cart_index and considerably more room for item options, shipping estimates, and cross-sell. Both share the same Alpine cart store and the same GraphQL mutations though.
2Why should a quantity change not fire on every single keystroke?
Without debouncing, every keystroke triggers its own GraphQL mutation, generating unnecessarily many parallel requests during fast typing. A short delay of around 500 milliseconds after the last input noticeably reduces server load.
3Which mutation handles quantity changes and item removal?
Both run through updateCartItems, where a quantity of zero is equivalent to removing the item. A dedicated removeItemFromCart mutation is also available, depending on which one is already implemented in the cart store.
4How should error messages look for an invalid coupon code?
Specific rather than generic: as far as the GraphQL response provides the reason, for example expired or minimum order value not reached, that exact text should be shown. A blanket message like invalid code creates unnecessary uncertainty for customers.
5Where should the cross-sell area be placed on the cart page?
Always below the item list and summary, never above the proceed to checkout button. Placing it above reads like an additional obstacle in the checkout funnel rather than an optional add-on.
6Why can the cart page not be served through the full page cache?
Because its content is inherently personalized and changes with every cart's contents. The basic HTML skeleton can still be cached as long as the actual content is fully loaded through GraphQL and rendered client side.
7What is true optimistic UI and why is it not generally recommended here?
Optimistic UI shows a change immediately before the server response arrives, which feels faster but briefly shows an incorrect state on error. A visible, subtle loading state is usually the more pragmatic middle ground.
8How do I prevent inconsistencies between the minicart counter and the cart page?
By having both use the same Alpine cart store as the single source of truth instead of maintaining separate states. Every change on the cart page must automatically land in the global store so the minicart counter in the header updates immediately.
9What does the cart page show once the last item has been removed?
A dedicated empty state view with a prompt to continue shopping, instead of an empty but structurally still present table. This state should be derived reactively from the item count in the cart store.
10Should cross-sell product images be lazy loaded?
Yes, since the cross-sell area usually only becomes visible below the initial viewport. loading lazy on the product images prevents them from unnecessarily degrading the load time of the upper cart section.