Hyvä Architecture and Trade-offs
Why Hyvä Exists
The default Magento Luma frontend uses KnockoutJS + RequireJS + jQuery. This stack was designed in 2013. The result:
Luma Frontend Problems:
├── 500KB+ JavaScript (even on homepage)
├── KnockoutJS: Heavy framework for simple UI
├── RequireJS: Async loading overhead
├── jQuery: Redundant with modern browsers
├── LESS compilation: Slow build times
├── FCP: 2-5 seconds on mobile
└── Lighthouse: 50-70 typical
Hyvä replaces this with:
Hyvä Stack:
├── Alpine.js: 15KB, declarative reactivity
├── Tailwind CSS: Utility-first, no LESS compilation
├── ES Modules: Native browser support
├── Vite: Fast builds, HMR
├── FCP: 0.5-1 second on mobile
└── Lighthouse: 90-100 typical
Architecture Comparison
Luma: Hyvä:
┌─────────────┠┌─────────────â”
│ KnockoutJS │ ↠Heavy │ Alpine.js │ ↠15KB
│ (200KB) │ │ │
├─────────────┤ ├─────────────┤
│ RequireJS │ ↠AMD loading │ ES Modules │ ↠Native
│ jQuery │ ↠Redundant │ No jQuery │
├─────────────┤ ├─────────────┤
│ LESS │ ↠Compilation │ Tailwind │ ↠Utility
│ compiled │ │ classes │
└─────────────┘ └─────────────┘
Data binding: Data binding:
data-bind="text: price" x-text="price"
data-bind="visible: inStock" x-show="inStock"
data-bind="click: addToCart" @click="addToCart()"
Real Trade-offs
Hyvä Advantages:
├── 10x less JavaScript
├── 3-5x faster page loads
├── Better mobile performance
├── Simpler debugging (no RequireJS)
├── Lower server load
└── Better Core Web Vitals
Hyvä Disadvantages:
├── Extension compatibility (30-50% of extensions need adaptation)
├── KnockoutJS knowledge not directly applicable
├── Learning curve for Alpine.js + Tailwind
├── Some Magento UI Components need rewriting
├── Checkout requires custom implementation
├── No built-in admin theme changes
└── Community support still growing
When to Use Hyvä:
├── New Magento builds (no legacy extensions)
├── Performance-critical stores (B2C, mobile-heavy)
├── Stores willing to invest in frontend rewrite
├── Teams comfortable with modern frontend tools
└── Budget for extension compatibility work
When to Stay with Luma:
├── Many third-party extensions with KnockoutJS
├── Heavy customization of existing Luma theme
├── Small team without frontend expertise
├── Budget constraints
└── Internal admin-focused stores
Real Migration Case Study
Store: Fashion retailer, 50K products, 70% mobile traffic
Before Luma:
├── FCP: 4.2 seconds
├── Lighthouse: 52
├── Bounce rate: 65%
├── Conversion rate: 1.8%
└── Extensions: 45 (12 with KnockoutJS)
After Hyvä (3 month migration):
├── FCP: 0.8 seconds
├── Lighthouse: 96
├── Bounce rate: 38% (-42%)
├── Conversion rate: 2.9% (+61%)
├── Extensions rewritten: 12
├── Development cost: $45,000
├── Monthly revenue increase: $35,000
└── Payback period: 6 weeks
What was hard:
├── 3 extensions had deep KnockoutJS integration
├── Custom checkout needed full rewrite
├── Some UI components had no Hyvä equivalent
└── Team needed 2 months to learn Alpine.js + Tailwind
Extension Compatibility
Compatibility Categories
Category 1: Works Out of the Box (60-70%)
├── Backend-only modules (no frontend changes)
├── Modules using standard PHTML blocks
├── Modules using only PHP (no JS)
└── Action: Install normally
Category 2: Needs Template Override (20-30%)
├── Modules with custom PHTML templates
├── Modules using LESS for styling
├── Modules with simple JavaScript
└── Action: Override templates, convert LESS → Tailwind
Category 3: Needs Rewrite (5-10%)
├── Modules using KnockoutJS components
├── Modules using RequireJS AMD modules
├── Modules with complex UI Components
└── Action: Rewrite with Alpine.js + Tailwind
Category 4: Incompatible (1-5%)
├── Modules deeply coupled to Luma
├── Modules using deprecated Magento JS
├── Modules that modify core JS behavior
└── Action: Find alternative or build custom
Compatibility Check Process
# 1. List all frontend modules
bin/magento module:status --enabled | grep -v Magento_
# 2. Check each module for compatibility
composer why hyva-themes/magento2-compatibility-module
# 3. Search for KnockoutJS usage
grep -r "knockoutjs\|ko.observable\|data-bind" app/code/
# 4. Search for RequireJS usage
grep -r "require\(['"]knockout\|define\(\[" app/code/
# 5. Search for jQuery usage
grep -r "\$\\|jQuery" app/code/
# 6. Check for Tailwind conflicts
grep -r "class=\".*bg-\|class=\".*text-\|class=\".*p-" app/code/
Common Migration Patterns
Pattern 1: KnockoutJS → Alpine.js
<!-- Luma: KnockoutJS -->
<div data-bind="visible: isOpen">
<span data-bind="text: items().length"></span>
</div>
<!-- Hyvä: Alpine.js -->
<div x-show="isOpen">
<span x-text="items.length"></span>
</div>
Pattern 2: RequireJS → ES Modules
// Luma: RequireJS
define(['jquery', 'mage/url'], function($, url) {
var urlBuilder = url.build('custom/action');
$.ajax({ "url": urlBuilder });
});
// Hyvä: ES Modules
import $ from 'jquery';
const urlBuilder = '/custom/action';
fetch(urlBuilder).then(r => r.json());
Pattern 3: LESS → Tailwind
// Luma: LESS
.product-card {
padding: 1rem;
background: white;
border-radius: 0.5rem;
&:hover {
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
}
/* Hyvä: Tailwind */
/* No CSS file needed - use utility classes */
<div class="p-4 bg-white rounded hover:shadow-md">
Extension Compatibility Layers
// Some extensions provide Hyvä compatibility modules
// Install via composer:
composer require hyva-themes/magento2-module-name
// Or create your own compatibility layer:
// 1. Create a module that overrides Luma templates
// 2. Convert KnockoutJS components to Alpine.js
// 3. Replace LESS with Tailwind classes
// 4. Test all functionality
// Example: Converting a custom warranty module
class WarrantyHyvaCompatibility
{
// Override Luma template
public function getTemplate(): string
{
return 'Vendor_Warranty::hyva/warranty-form.phtml';
}
// Convert KO component to Alpine
public function getAlpineComponent(): string
{
return <<<JS
{
warrantySelected: false,
toggleWarranty() {
this.warrantySelected = !this.warrantySelected;
}
}
JS;
}
}
Building a Custom Hyvä Module
Module Structure
app/code/Vendor/Warranty/├── registration.php
├── etc/module.xml
├── composer.json
├── view/
│ └── frontend/
│ ├── layout/
│ │ ├── catalog_product_view.xml
│ │ └── checkout_cart_index.xml
│ ├── templates/
│ │ ├── warranty-form.phtml
│ │ └── warranty-summary.phtml
│ ├── web/
│ │ ├── js/
│ │ │ └── warranty.js
│ │ └── css/
│ │ └── warranty.css (optional)
│ └── requirejs-config.js (if needed)
└── Block/
└── Warranty.php
Layout XML for Hyvä
<!-- catalog_product_view.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="product.info.addtocart">
<block class="Vendor\Warranty\Block\Warranty"
name="warranty.options"
template="Vendor_Warranty::warranty-form.phtml"
after="product.info.addtocart.addto"/>
</referenceBlock>
</body>
</page>
Alpine.js Component
<!-- warranty-form.phtml -->
<?php
/** @var \Vendor\Warranty\Block\Warranty $block */
$warrantyOptions = $block->getWarrantyOptions();
?>
<div x-data="warrantyForm()" class="mt-4 border rounded p-4">
<h3 class="text-lg font-bold mb-2">Warranty Options</h3>
<?php foreach ($warrantyOptions as $option): ?>
<label class="flex items-center space-x-2 mb-2 cursor-pointer">
<input type="radio"
name="warranty"
value="<?= $block->escapeHtmlAttr($option['id']) ?>"
x-model="selectedWarranty"
class="text-blue-600">
<span class="text-sm">
<?= $block->escapeHtml($option['name']) ?>
- $<?= number_format($option['price'], 2) ?>
</span>
</label>
<?php endforeach; ?>
<label class="flex items-center space-x-2 mt-4">
<input type="checkbox"
x-model="agreeToTerms"
class="text-blue-600">
<span class="text-sm text-gray-600">
I agree to warranty terms
</span>
</label>
<div x-show="selectedWarranty && !agreeToTerms"
x-text="'Please agree to warranty terms'"
class="text-red-500 text-sm mt-2">
</div>
</div>
<script>
function warrantyForm() {
return {
selectedWarranty: null,
agreeToTerms: false,
init() {
// Watch for changes
this.$watch('selectedWarranty', (value) => {
// Update cart warranty
this.updateWarranty(value);
});
},
async updateWarranty(warrantyId) {
if (!warrantyId) return;
const response = await fetch('/warranty/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
warranty_id: warrantyId,
product_id: <?= $block->getProductId() ?>
})
});
if (!response.ok) {
console.error('Failed to update warranty');
}
}
};
}
</script>
Block Class
<?php
namespace Vendor\Warranty\Block;
use Magento\Framework\View\Element\Template;
class Warranty extends Template
{
private $warrantyRepository;
private $serializer;
public function __construct(
Template\Context $context,
\Vendor\Warranty\Api\WarrantyRepositoryInterface $warrantyRepository,
\Magento\Framework\Serialize\Serializer\Json $serializer,
array $data = []
) {
parent::__construct($context, $data);
$this->warrantyRepository = $warrantyRepository;
$this->serializer = $serializer;
}
public function getWarrantyOptions(): array
{
return $this->warrantyRepository->getActiveWarranties();
}
public function getProductId(): int
{
return (int) $this->getProduct()->getId();
}
public function getJsonConfig(): string
{
return $this->serializer->jsonEncode([
'productId' => $this->getProductId(),
'warrantyUrl' => $this->getUrl('warranty/update'),
]);
}
}
Testing Checklist
â–¡ Product page renders warranty form
â–¡ Alpine.js components initialize correctly
â–¡ Warranty selection updates cart
â–¡ Checkout shows warranty items
â–¡ Admin shows warranty in order
â–¡ Mobile responsive
â–¡ Accessible (keyboard navigation, screen reader)
â–¡ Performance: No extra JS bundles loaded
Hyvä Checkout Implementation
The Checkout Challenge
Magento's default checkout uses KnockoutJS extensively. Hyvä provides hyva/checkout module that replaces it with Alpine.js. This is the hardest part of migration.
Default Checkout (KnockoutJS):
├── Checkout index with multiple steps
├── Shipping, Payment, Review steps
├── KnockoutJS components for each
├── RequireJS modules for logic
└── 200KB+ JavaScript
Hyvä Checkout (Alpine.js):
├── Single-page checkout (optional steps)
├── Alpine.js components
├── ES Modules for logic
├── Minimal JavaScript
└── Better mobile UX
Hyvä Checkout Module
# Install Hyvä checkout
composer require hyva-themes/magento2-checkout
bin/magento module:enable Hyva_Checkout
bin/magento setup:upgrade
Custom Checkout Step
<!-- app/code/Vendor/CustomCheckout/view/frontend/templates/checkout/gift-options.phtml -->
<div x-data="giftOptions()" class="checkout-step">
<h2 class="text-xl font-bold mb-4">Gift Options</h2>
<div class="space-y-4">
<label class="flex items-center space-x-2">
<input type="checkbox"
x-model="includeGiftMessage"
class="text-blue-600">
<span>Include gift message</span>
</label>
<div x-show="includeGiftMessage" x-transition class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Recipient Name</label>
<input type="text"
x-model="recipientName"
class="w-full border rounded px-3 py-2"
placeholder="Recipient name">
</div>
<div>
<label class="block text-sm font-medium mb-1">Gift Message</label>
<textarea x-model="giftMessage"
class="w-full border rounded px-3 py-2"
rows="3"
placeholder="Your message..."></textarea>
</div>
</div>
<button @click="saveGiftOptions()"
:disabled="!canSave"
class="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50">
Save Gift Options
</button>
</div>
</div>
<script>
function giftOptions() {
return {
includeGiftMessage: false,
recipientName: '',
giftMessage: '',
get canSave() {
if (!this.includeGiftMessage) return true;
return this.recipientName.length > 0 && this.giftMessage.length > 0;
},
async saveGiftOptions() {
const data = {
include_gift_message: this.includeGiftMessage,
recipient_name: this.recipientName,
gift_message: this.giftMessage
};
// Save to checkout data
window.checkoutData = {
...window.checkoutData,
gift_options: data
};
// Move to next step
this.$dispatch('checkout:next-step');
}
};
}
</script>
Payment Method Integration
<!-- Payment method with Alpine.js -->
<div x-data="paymentMethod()" class="payment-method">
<label class="flex items-center space-x-3 p-4 border rounded cursor-pointer"
:class="{ 'border-blue-500 bg-blue-50': isSelected }">
<input type="radio"
name="payment_method"
value="custom_payment"
x-model="selectedMethod"
class="text-blue-600">
<div>
<div class="font-medium">Custom Payment</div>
<div class="text-sm text-gray-500">Pay with custom gateway</div>
</div>
</label>
<div x-show="isSelected" x-transition class="mt-4 p-4 border rounded">
<div class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Card Number</label>
<input type="text"
x-model="cardNumber"
class="w-full border rounded px-3 py-2"
placeholder="1234 5678 9012 3456"
maxlength="19">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">Expiry</label>
<input type="text"
x-model="expiry"
class="w-full border rounded px-3 py-2"
placeholder="MM/YY"
maxlength="5">
</div>
<div>
<label class="block text-sm font-medium mb-1">CVV</label>
<input type="text"
x-model="cvv"
class="w-full border rounded px-3 py-2"
placeholder="123"
maxlength="4">
</div>
</div>
</div>
</div>
</div>
<script>
function paymentMethod() {
return {
selectedMethod: null,
cardNumber: '',
expiry: '',
cvv: '',
get isSelected() {
return this.selectedMethod === 'custom_payment';
},
formatCardNumber(e) {
let value = e.target.value.replace(/\s/g, '').replace(/\D/g, '');
let formatted = value.match(/.{1,4}/g)?.join(' ') || value;
this.cardNumber = formatted;
}
};
}
</script>
Performance Comparison
Checkout Performance:
Luma Checkout:
├── JS Bundle: 350KB+
├── FCP: 3-4 seconds
├── Steps: 3 (Shipping → Payment → Review)
├── Network requests: 15-20
└── Mobile: Poor (heavy JS)
Hyvä Checkout:
├── JS Bundle: 30KB
├── FCP: 0.8-1.2 seconds
├── Steps: Flexible (single page or multi-step)
├── Network requests: 5-8
└── Mobile: Excellent (lightweight)
Real impact:
├── Checkout conversion: +25% (faster = more completions)
├── Mobile conversion: +40% (mobile users most affected)
├── Cart abandonment: -15% (less friction)
└── Revenue impact: $50K+/month for mid-size store
Quiz
1. What is the main reason to migrate from Luma to Hyvä?
2. What percentage of Magento extensions typically need rewrite for Hyvä?
3. What is the hardest part of Hyvä migration?
4. What is the typical Lighthouse score improvement from Luma to Hyvä?
Flashcards
Question
Hyvä JS stack?
Click to reveal answer
Answer
Alpine.js (15KB) instead of KnockoutJS (200KB+)
Question
Hyvä CSS stack?
Click to reveal answer
Answer
Tailwind CSS instead of LESS compilation
Question
Extension compatibility?
Click to reveal answer
Answer
60-70% work, 20-30% need templates, 5-10% need rewrite
Question
Hardest migration part?
Click to reveal answer
Answer
Checkout (deeply coupled to KnockoutJS)
Question
Typical conversion rate improvement?
Click to reveal answer
Answer
+25% overall, +40% on mobile
Revision Notes
Key Takeaways
- 1. Hyvä replaces Luma stack: Alpine.js + Tailwind vs KnockoutJS + RequireJS + jQuery
- 2. 10x less JavaScript, 3-5x faster page loads, 90+ Lighthouse scores
- 3. 5-10% of extensions need full rewrite, 20-30% need template overrides
- 4. Checkout is hardest migration part — Hyvä provides module but custom steps need rewrite
- 5. ROI is strong: $45K migration cost, $35K/month revenue increase for mid-size store
Interview Tips
- • Explain Hyvä vs Luma trade-offs with specific metrics
- • Discuss extension compatibility categories and migration process
- • Walk through building a custom Hyvä module with Alpine.js
- • Analyze Hyvä checkout implementation vs Luma
- • Evaluate when to use Hyvä vs stay with Luma
Cheat Sheet
Hyvä Theme
- JS: Alpine.js (15KB) vs KnockoutJS (200KB+)
- CSS: Tailwind vs LESS
- Build: Vite vs static deploy
- Lighthouse: 90-100 vs 50-70
- Extensions: 60-70% work, 5-10% rewrite
- Checkout: Hardest part, use hyva/checkout module
- ROI: ~$45K cost, ~$35K/month revenue increase