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.
Table of Contents
- 1. Why Hyva ships without a ready-made compare feature
- 2. The native compare product API instead of a custom build
- 3. An Alpine store for the compare list across multiple pages
- 4. Wiring the compare widget into the product grid
- 5. Guest uid and merging on login
- 6. Making the comparison table responsive
- 7. Highlighting differences between products
- 8. Performance and full page cache compatibility
- 9. Common mistakes with the compare feature in Hyva
- 10. Summary
- 11. FAQ
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.