Product Compare Pages in Hyva: Alpine.js and the Native GraphQL Compare API
AI generated
Hyvä
phtml
Hyva Theme · Product Compare
Product Compare Pages in Hyva Theme
Building a compare page with Alpine.js and the native GraphQL compare API

Hyva ships a minicart and a wishlist out of the box, but a ready-made compare feature is nowhere to be found in the default theme. If your catalog needs one anyway, do not start from scratch: build on Magento's native compare product API and wire it up with a clean Alpine store and a responsive table.

13 min read Compare List API Alpine Store localStorage GraphQL uid Responsive Table

1. Why Hyva ships without a ready-made compare feature

Hyva Themes deliberately trims the default frontend down to what most shops actually need. Minicart, wishlist, and checkout are part of that core set, but a compare page is not central to the shopping experience for most catalogs, so it is missing from the default theme. That is not a technical gap but a conscious reduction that keeps shops from shipping unused complexity.

For catalogs with many comparable variants, such as electronics, tools, or technical components, a product compare feature is still a genuine conversion driver. Instead of building an entirely custom data layer, it is worth looking at the compare product API that Magento has shipped for years and that has been fully accessible via GraphQL for several versions now. That lets you add the feature back in Hyva style without reactivating the legacy blocks from the Luma theme.

2. The native compare product API instead of a custom build

Magento manages compare lists server side through its own uid, exactly analogous to the masked cart id of a guest order. The createCompareList mutation creates a new list and returns the uid, addProductsToCompareList and removeProductsFromCompareList maintain its contents, and the compareList query returns every product along with the attributes needed for the comparison table. This uid is stored client side and sent along with every subsequent request, just like the cart id during checkout.

The advantage over a custom solution with a bespoke attribute or a dedicated database table is obvious: the compare product API already understands configurability, price rules, store views, and permissions, it is maintained with every core update, and it works identically for guests and logged in customers. Rolling your own here rarely pays off, the wheel has already been invented and is only one GraphQL query away.


mutation CreateCompareList {
  createCompareList(input: { uid: null }) {
    uid
    item_count
    items {
      uid
      product {
        sku
        name
      }
    }
  }
}

mutation AddToCompare($listUid: ID!, $productUid: ID!) {
  addProductsToCompareList(
    input: { uid: $listUid, products: [$productUid] }
  ) {
    uid
    item_count
  }
}

3. An Alpine store for the compare list across multiple pages

For the compare list to survive a move from one category page to the next, and a hard page reload, an x-data scoped to a single component is not enough. The uid needs to live in a global place, which is why an Alpine.store that is read from localStorage on page start and written back on every change makes sense. Hyva already loads Alpine globally, so no extra plugin is needed for this.

It is important to initialize the store exactly once before any component reads it, otherwise the compare counter briefly flickers on first render. In practice this means registering the store in its own inline script right before the closing body tag, the same way Hyva already does for its existing wishlist store.


document.addEventListener('alpine:init', () => {
  Alpine.store('compare', {
    uid: localStorage.getItem('compare_list_uid') || null,
    items: JSON.parse(localStorage.getItem('compare_items') || '[]'),
    count: 0,

    init() {
      this.count = this.items.length;
    },

    async addItem(productUid, listMutation) {
      const result = await listMutation(this.uid, productUid);
      this.uid = result.uid;
      this.count = result.item_count;
      localStorage.setItem('compare_list_uid', this.uid);
    },

    isInList(productUid) {
      return this.items.some((item) => item.productUid === productUid);
    },
  });
});

4. Wiring the compare widget into the product grid

The compare widget is placed as a small button in the existing product card, usually in the same override of Magento_Catalog::product/list.phtml where the wishlist button already lives. Instead of a new block, a plain Alpine fragment is enough: it reads from the global store directly and picks up the product uid as a data attribute rendered server side.

Visual feedback matters here: a product already on the compare list must stand out from the rest of the card, otherwise customers click the same button repeatedly and get confused by errors when trying to add it again. A simple x-bind:class expression based on isInList already covers this.


<div class="product-item" x-data="{ productUid: '{{ $productUid }}' }">
  <button
    type="button"
    class="flex items-center gap-1 text-sm"
    x-bind:class="$store.compare.isInList(productUid)
      ? 'text-orange-600 font-semibold'
      : 'text-gray-500 hover:text-gray-700'"
    x-on:click="$store.compare.isInList(productUid)
      ? removeFromCompare(productUid)
      : $store.compare.addItem(productUid, addToCompareList)"
  >
    <svg class="w-4 h-4" aria-hidden="true"><!-- Icon --></svg>
    <span x-text="$store.compare.isInList(productUid) ? 'In compare' : 'Compare'"></span>
  </button>
</div>

5. Guest uid and merging on login

Guests receive their compare uid the first time they add a product and keep it via localStorage until browser storage gets cleared. If a customer logs in during an active session, the guest list must be merged with any list that may already exist on the customer account, otherwise the previously marked products vanish at login. Magento provides the assignCompareListToCustomer mutation for exactly this, called from the login flow right after successful authentication.

In practice this means the login handler in the Hyva checkout, or the customer module, needs an extra GraphQL call that passes the guest uid stored in the Alpine store. After a successful merge the localStorage uid gets removed, because from that point on the compare list is tied to the customer session and no longer needs client side management.

6. Making the comparison table responsive

A comparison table with four or five products and a dozen attributes breaks almost any layout on mobile devices. Rather than shrinking the table until nothing is legible anymore, horizontal scrolling with a pinned first column works far more reliably: the attribute labels stay put on the left while the product columns scroll sideways.

With Tailwind this needs no extra JavaScript at all, position sticky on the first column combined with overflow-x-auto on the wrapping container is enough. On very small screens, an Alpine-driven accordion per product can be a better fit than a table altogether, especially once more than three products are being compared.


<div class="overflow-x-auto">
  <table class="min-w-full border-collapse text-sm">
    <thead>
      <tr>
        <th class="sticky left-0 bg-white z-10 p-3 text-left w-40">Attribute</th>
        <template x-for="product in $store.compare.items" :key="product.uid">
          <th class="p-3 min-w-[180px] text-left" x-text="product.name"></th>
        </template>
      </tr>
    </thead>
    <tbody>
      <template x-for="attr in attributes" :key="attr.code">
        <tr class="border-t">
          <td class="sticky left-0 bg-white z-10 p-3 font-medium" x-text="attr.label"></td>
          <template x-for="product in $store.compare.items" :key="product.uid">
            <td class="p-3" x-text="product.attributes[attr.code]"></td>
          </template>
        </tr>
      </template>
    </tbody>
  </table>
</div>

7. Highlighting differences between products

The real value of a compare page only emerges once differences between products stand out immediately, instead of forcing customers to read every row one by one. For that, each attribute row is checked for whether all values are identical, and a subtle highlight, for example a lightly tinted background, gets applied to the cells that deviate from the majority value.

This logic can be computed entirely client side in Alpine, as soon as the product data from the GraphQL response sits in the store, with no server side processing needed at all. It is important not to compare numeric attributes like weight or battery life on pure text equality, but on a normalized value, otherwise 2.0 kg and 2 kg get incorrectly flagged as different.

8. Performance and full page cache compatibility

Since the entire compare logic runs through GraphQL mutations, which naturally are not served through the full page cache, the actual product page remains untouched by the compare feature and stays fully cacheable. The compare counter in the header is rendered purely client side from the Alpine store, similar to the cart quantity in the minicart, so no ESI blocks or extra cache exceptions are needed.

For the GraphQL query behind the comparison table itself, it pays off to fetch only the attributes actually displayed, instead of pulling every product field indiscriminately. An overly broad query adds unnecessary response time and transfers data that never gets rendered on the compare page anyway, which is especially noticeable for products with many configurable attributes.

9. Common mistakes with the compare feature in Hyva

The most common mistake is keeping the compare uid only in the Alpine store instead of also persisting it in localStorage. After a hard reload the store is empty, but the server side list still exists, so customers appear to see an empty compare list even though the content is still there on the server. Consistently syncing store and localStorage in both directions reliably avoids this problem.

A second, often overlooked issue is a missing merge on login: if assignCompareListToCustomer is forgotten, the customer loses every product they marked as a guest at the moment they log in, with no error message shown, which reads like silent data loss from the customer's point of view. Just as important is registering every inline script through hyvaCsp registerInlineScript, otherwise the content security policy blocks the store and the compare buttons simply do not respond.

Building Block Responsibility Technology Persistence
Compare uid Unique identifier of the compare list GraphQL createCompareList localStorage (guest), customer account (logged in)
Compare store Global state across every page Alpine.store In memory plus localStorage sync
Compare widget Add/remove inside the product grid phtml plus Alpine x-data None, reads from the store
Comparison table Rendering and attribute diffing GraphQL compareList query None, rendered entirely client side
Login merge Merging guest and customer lists assignCompareListToCustomer Server side persistent from login onward

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

Product Compare in Hyva: Key Takeaways

No custom build

Use Magento's compare product API via GraphQL instead of a bespoke data layer.

Alpine store

A global store with localStorage sync keeps the compare list alive across page changes.

Responsive table

A sticky first column plus horizontal scrolling beats a barely readable mini table.

Login merge

assignCompareListToCustomer prevents data loss when a guest becomes a customer.

11. FAQ: Product Compare in Hyva: Key Takeaways

1Does Hyva ship a ready-made compare feature?
No, unlike the minicart and wishlist, a compare page is not part of Hyva Theme's default scope. It needs to be added on top of the native Magento compare product API, which in practice is a manageable amount of work.
2Why not just build a custom compare table in the database?
Because the native compare product API already correctly handles configurability, price rules, store views, and the guest to customer merge, and it stays current with every core update. A custom solution would need to rebuild and maintain all of that indefinitely.
3How does the compare uid differ from the cart quote id?
Both work on the same principle: a client side stored, masked identifier that gets sent along with every GraphQL request. The compare uid identifies the compare list, the quote id identifies the cart, and the two are valid independently of each other.
4How many products can be compared at once?
This is controlled through Magento configuration and is not technically hard coded. In practice four to five products make sense, more quickly makes the comparison table itself hard to read even on large screens.
5What happens to a guest's compare list after login?
Without an explicit merge call it simply gets lost, because the customer session references its own, separate list. The assignCompareListToCustomer mutation merges both lists and must be actively called from the login flow.
6Does the compare feature break the full page cache?
No, as long as the entire logic runs through GraphQL mutations and a client side Alpine store, the actual product page stays fully cacheable. No ESI blocks or cache exceptions are needed for the compare counter.
7How do I render the comparison table on small screens?
Horizontal scrolling with a sticky first column for the attribute labels works reliably. With more than three products, an Alpine-driven accordion per product is often a clearer alternative to a classic table.
8How do I highlight different attribute values in the table?
Client side in Alpine, once the product data from the GraphQL response is available: each row checks whether all values are identical, and applies a subtle background color on deviation. Numeric values should be compared normalized rather than as plain text.
9Do I need to register the compare store as a CSP inline script?
Yes, every inline script that initializes the Alpine store must be approved via hyvaCsp registerInlineScript. Without that registration the content security policy blocks the script, and the compare buttons stop responding to clicks.
10Can I combine the compare widget with the existing wishlist button?
Yes, both can sit side by side in the same product card override as long as each button talks to its own Alpine store. It only matters that the x-data scopes do not overlap and that each button correctly references its own product uid.