Appearance
Making a Luma Extension Compatible
This page walks an extension vendor or agency through porting the storefront part of an existing Magento extension, built for Luma, to a Lynx store. The server side of your extension (models, repositories, controllers, observers, plugins on services, cron, admin, REST, GraphQL, emails) keeps working as it is. What you rebuild is the storefront UI: layout XML blocks, .phtml templates, KnockoutJS and jQuery widgets, RequireJS mixins.
For the list of what Magento does differently on a Lynx store, see Differences from Standard Magento. This page is the step-by-step version.
What to port
Start with an inventory of everything your extension sends to the storefront, then map each piece:
| Luma piece | On Lynx | Where to read |
|---|---|---|
Block + .phtml placed in a page through layout XML (catalog_product_view.xml…) | A component plus a PHP provider. The provider calls the same repository, view model or service the block used; the component draws what the template drew. Merchants place it in PageBuilder | Creating Components, Component Providers |
KnockoutJS UI component, jQuery widget, x-magento-init / data-mage-init config | The same component, in Vue. The JSON config the widget received becomes provider props | Frontend Code |
RequireJS mixin or map override of core JS (catalog-add-to-cart, Magento_Checkout/js/…) | That JavaScript is not loaded. Run your code at the same moment with an observer on a Lynx event (checkout_cart_product_add_before…), add data with a plugin on the Lynx provider's resolve(), or place your component in a slot | Adding to Built-in Components, Themes and Overrides |
| Block added inside a core list through layout XML (per cart item, per product card, under the order items) | A block in a Lynx container (checkout.cart.item.actions, category.product.labels…), with its data from a plugin on the component's provider | Adding to Built-in Components |
Plugin on a block's output (afterToHtml, *_html_after observers) | Does not run: the block is never created. Hook into the data the Lynx provider reads instead | Layout XML |
Customer data section (sections.xml + SectionSourceInterface) | Works unchanged. Read it with the customer data store; reload it after your own requests | Customer data below |
| AJAX controller returning JSON or a redirect | Keep it. Call it from the component with fetch or an Inertia router.post | Posting to the controller below |
| Storefront page with its own route and layout | A layout-driven page merchants design, or a page component returned from the controller | Pages below |
Customer account tab (customer_account_navigation) | An entry in the Lynx account sidebar and a section component | My Account Tabs |
Checkout layoutProcessor, checkout JS components, payment method renderer | A payment or shipping renderer, or a component in a checkout slot | Checkout extensions below |
<script> / <link> in default.xml, verification tags | The same instructions in lynx_default.xml, or a headless component in the Backstage zone | Layout XML, Forms and Analytics |
| Tracking script that listens to DOM events or KnockoutJS observables | A tracker on the analytics bus | Forms and Analytics |
reCAPTCHA key under recaptcha_frontend/type_for with its predispatch observer | Works unchanged. Add data-recaptcha="<key>" to the component's form | Forms and Analytics |
i18n/<locale>.csv, including phrases used with $t() in JS | Same CSV files. The provider translates the strings with __() and sends them as labels | Translations |
Widget type (widget.xml) inserted on CMS pages | directives in CMS content still render as HTML, without their scripts. For merchants building pages in PageBuilder, offer a component | CMS content and widgets |
| Emails, admin, REST, GraphQL, cron, observers, plugins on models and services | Unchanged |
Lynx and Hyvä
Lynx cannot be used together with Hyvä. They are two different frontend stacks: Hyvä renders Magento layout XML and .phtml templates with Alpine.js, while Lynx is a Vue single-page app on Inertia.js, with pages built from PageBuilder components. An extension's Hyvä compatibility module therefore does not work on Lynx. Port the frontend to Lynx components as described on this page; the extension's PHP logic, controllers and services are reused either way.
Package layout
Put the Lynx storefront in a separate compatibility module that depends on both your extension and Lynx:
Separate module Vendor_ProductQuestionsLynx | Inside the extension, guarded by <sequence> | |
|---|---|---|
| Stores without Lynx | Do not install it; they get no Lynx code and no Lynx requirement in composer.json | Get the Lynx files, and need the Lynx modules present for setup:di:compile to pass on classes that extend or reference them |
| Build | Only stores that install it add its components to the theme bundle | Same, but every store carries the source |
| Releases | Ship Lynx fixes without a new extension version, and the other way around | One version for both storefronts |
<sequence> orders module loading; it does not make a module optional. A provider that extends OOCS\LynxComponent\Model\AbstractComponentProvider fails to compile when OOCS_LynxComponent is missing, so a combined module forces Lynx onto every store. Keep the two apart. Lynx's own add-ons follow the same pattern: OOCS_LynxPaymentStripe holds only the Lynx front of the Stripe module and requires it.
etc/module.xml:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Vendor_ProductQuestionsLynx">
<sequence>
<!-- The extension this module puts a Lynx storefront on -->
<module name="Vendor_ProductQuestions"/>
<!-- Component registry and provider contract -->
<module name="OOCS_LynxComponent"/>
<!-- Only for the editor page list and the starting layout -->
<module name="OOCS_LynxPageBuilder"/>
<!-- Only for the account frame and sidebar -->
<module name="OOCS_LynxCustomer"/>
</sequence>
</module>
</config>composer.json of the compatibility module:
json
{
"name": "vendor/module-product-questions-lynx",
"description": "Lynx storefront for Vendor_ProductQuestions",
"type": "magento2-module",
"require": {
"php": ">=8.2",
"vendor/module-product-questions": "^2.0",
"oocscommerce/module-lynx-component": "^1.0",
"oocscommerce/module-lynx-page-builder": "^1.0",
"oocscommerce/module-lynx-customer": "^1.0"
},
"autoload": {
"files": ["registration.php"],
"psr-4": { "Vendor\\ProductQuestionsLynx\\": "" }
}
}Leave out oocscommerce/module-lynx-page-builder and oocscommerce/module-lynx-customer if you register no pages and no account section. In the extension's own composer.json, point to the compatibility module without requiring it:
json
"suggest": {
"vendor/module-product-questions-lynx": "Storefront for stores running a Lynx theme"
}Folder tree:
Vendor/ProductQuestionsLynx/
├── composer.json
├── registration.php
├── etc/
│ ├── module.xml
│ ├── components/
│ │ ├── Questions.graphqls ← one file per component
│ │ └── AccountQuestions.graphqls
│ ├── di.xml ← editor pages (global scope)
│ └── frontend/di.xml ← account sidebar tab
├── i18n/en_US.csv ← only phrases the extension does not have yet
├── Provider/
│ ├── Questions.php
│ └── AccountQuestions.php
├── Setup/Patch/Data/InstallAccountLayout.php
└── view/frontend/
├── layout/lynx_productquestions_my_index.xml
└── lynx/
└── vue/src/Components/
├── Questions.vue
└── AccountQuestions.vueWorked example: Product Questions
The Luma extension Vendor_ProductQuestions adds a Questions & Answers block to the product page:
catalog_product_view.xmladds the blockVendor\ProductQuestions\Block\Product\Questionstoproduct.info.details, with a template listing approved questions fromQuestionRepositoryInterface.- A KnockoutJS form posts
product_id,nicknameandquestionby AJAX toproductquestions/question/post, which answers{"success": true, "message": "…"}. - Guests must sign in unless Allow Guests to Ask is on. The form is protected by its own reCAPTCHA key,
vendor_product_question. - The customer data section
product-questionslists the signed-in customer's questions awaiting moderation, andsections.xmlinvalidates it onproductquestions/question/post. - Translations in
i18n/*.csv, some used only through$t()in the form's JavaScript.
None of the server side changes. The port adds one component with one provider.
1. Register the component
In the compatibility module's etc/components/Questions.graphqls (see Creating Components):
graphql
"Approved questions and answers about the product, and the form to ask one"
type VendorProductQuestions @component(
code: "vendor_product_questions"
template: "Vendor_ProductQuestionsLynx::Components/Questions"
provider: "Vendor\\ProductQuestionsLynx\\Provider\\Questions"
label: "Product Questions"
group: "general"
icon: "ph-question"
zones: ["content"]
availableOn: ["catalog_product_view*"]
) {
"Off: sent with the page and visible to search engines. On: fetched only when the block appears, for example when its tab is opened"
lazy: Boolean! @setting(label: "Load when shown", default: false)
page_size: Int! @setting(label: "Questions shown", default: 10, min: 1, max: 50, step: 1)
"Written first by the provider: missing means not fetched yet"
enabled: Boolean! @optional
product_id: Int! @optional
allow_guest: Boolean! @optional
items: [VendorProductQuestion!]! @optional
urls: VendorProductQuestionsUrls! @optional
"Customer-data sections a post invalidates, from the extension's sections.xml"
sections: [String!]! @optional
labels: StringMap! @optional
}
type VendorProductQuestion {
id: Int!
question: String!
answer: String!
nickname: String!
date: String!
}
type VendorProductQuestionsUrls {
post: String!
login: String!
}Every key the provider returns is declared: the server drops the others before they reach the browser. bin/magento lynx:static:deploy turns the file into the TypeScript types Lynx.VendorProductQuestions and Lynx.VendorProductQuestion that the components use.
Choosing the group. The group decides which slots accept the component. The slots of Product Detail (Below Price, Below Add to Cart) and of Product Media (Below Gallery) accept layout, text, media, form, product_list, product_attribute and general, but not product_page. The Images and Details slots of Product accept those and product_page too. With general, merchants can put the block in a Tab under the product (the Luma position), in a Group of its own, under the gallery or in Below Add to Cart. With product_page it would be listed next to Reviews in the picker and could go beside the images in Product's slots, but not into the Product Detail or Product Media slots. See the groups table.
catalog_product_view* covers every product type, since each type has its own product page.
2. The provider
The provider plays the role of the Luma block: it reads current_product, calls the extension's repository and config, and adds everything the component prints.
php
<?php
declare(strict_types=1);
namespace Vendor\ProductQuestionsLynx\Provider;
use Magento\Catalog\Model\Product;
use Magento\Framework\Registry;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Magento\Framework\UrlInterface;
use Magento\Store\Model\StoreManagerInterface;
use OOCS\LynxComponent\Helper\SectionInvalidation;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
use OOCS\LynxComponent\Model\PageCacheIdentities;
use Vendor\ProductQuestions\Api\QuestionRepositoryInterface;
use Vendor\ProductQuestions\Model\Config;
use Vendor\ProductQuestions\Model\Question;
class Questions extends AbstractComponentProvider
{
public function __construct(
private readonly Registry $registry,
private readonly Config $config,
private readonly QuestionRepositoryInterface $questionRepository,
private readonly StoreManagerInterface $storeManager,
private readonly TimezoneInterface $timezone,
private readonly UrlInterface $urlBuilder,
private readonly SectionInvalidation $sectionInvalidation,
private readonly PageCacheIdentities $pageCacheIdentities,
) {
}
/**
* Left out of the page until the component asks, when lazy
*
* @param array $props
* @return bool
*/
public function optional(array $props): bool
{
// Never lazy when disabled: Tabs hides a tab whose block answers enabled: false
return $this->config->isEnabled() && !empty($props['lazy']);
}
/**
* Approved questions of the viewed product and what the form needs
*
* @param array $props
* @return array
*/
public function resolve(array $props): array
{
/** @var Product|null $product */
$product = $this->registry->registry('current_product');
if (!$product) {
return $props;
}
$props['enabled'] = $this->config->isEnabled();
if (!$props['enabled']) {
return $props;
}
// The tags the Luma block returned from getIdentities(); a lazy request is cached on its own
$tags = [Question::CACHE_TAG];
if (!empty($props['lazy'])) {
$tags[] = Product::CACHE_TAG . '_' . $product->getId();
}
$this->pageCacheIdentities->add($tags);
$questions = $this->questionRepository->getApprovedForProduct(
(int) $product->getId(),
(int) $this->storeManager->getStore()->getId(),
max(1, (int) $props['page_size'])
);
$props['product_id'] = (int) $product->getId();
$props['allow_guest'] = $this->config->isGuestAllowed();
$props['items'] = array_map(fn ($question) => [
'id' => (int) $question->getId(),
'question' => (string) $question->getQuestion(),
'answer' => (string) $question->getAnswer(),
'nickname' => (string) $question->getNickname(),
'date' => $this->timezone->formatDate($question->getCreatedAt(), \IntlDateFormatter::MEDIUM),
], $questions);
$props['urls'] = [
'post' => $this->urlBuilder->getUrl('productquestions/question/post'),
'login' => $this->urlBuilder->getUrl('customer/account/login'),
];
// From the extension's sections.xml: what to reload after a successful post
$props['sections'] = $this->sectionInvalidation->getForPath('productquestions/question/post');
// The extension's own phrases, so its CSV files translate them
$props['labels'] = [
'title' => (string) __('Questions & Answers'),
'empty' => (string) __('No questions yet. Be the first to ask.'),
'askedBy' => (string) __('Asked by %1 on %2'),
'answer' => (string) __('Answer'),
'ask' => (string) __('Ask a Question'),
'nickname' => (string) __('Nickname'),
'question' => (string) __('Your Question'),
'submit' => (string) __('Submit Question'),
'required' => (string) __('This is a required field.'),
'pending' => (string) __('Awaiting moderation'),
'loginRequired' => (string) __('Only registered customers can ask questions. Please %1.'),
'signIn' => (string) __('Sign In'),
'error' => (string) __('We can\'t submit your question right now.'),
];
return $props;
}
}Points carried over from the Luma block:
- Do not read the customer session here. The product page is in the full page cache, so whatever
resolve()returns is shared by every visitor. Per-visitor state (signed in or not, the nickname, pending questions) comes from the customer data store in the browser, as it did in Luma through KnockoutJS. - Cache tags. The Luma block's
getIdentities()no longer runs, because the block is never created. Add the same tags throughPageCacheIdentities, so saving a question still refreshes the pages that show it. The product page already carries the product's tags, so a lazy request is the only one that needs them too. - Labels. Every phrase goes through
__()on the server. Phrases the extension used with$t()in JavaScript are in its CSV files already and translate the same way. Add new phrases to the compatibility module'si18n/en_US.csv, and reuse Magento's own ("Sign In", "This is a required field.") where they fit. - Lazy loading follows the
lazyconvention, so the block can wait until its tab is opened.
3. Data the provider needs from Lynx
| Need | Use |
|---|---|
| Current product or category | current_product / current_category from Magento\Framework\Registry |
| Sections an action invalidates | OOCS\LynxComponent\Helper\SectionInvalidation::getForPath('<route>'), exact route from sections.xml |
| Full page cache tags | OOCS\LynxComponent\Model\PageCacheIdentities::add() |
Text the merchant typed (_i18n settings) | OOCS\LynxComponent\Helper\LocalizedText::get() |
| Image and link settings | MediaUrl and StoreLink in OOCS_LynxThemeComponent, see Setting Types |
4. Translations
Nothing to move. Magento merges the CSV files of every module into one dictionary per locale, so the extension's i18n/de_DE.csv translates the labels sent by the compatibility module. Translations the merchant stored in the database through Inline Translation do not apply on Lynx store views; ask them to move those to a CSV file or language pack. If the store uses compiled configuration, CSV changes show after bin/magento lynx:compile translate.
5. The component
The component replaces the template and the KnockoutJS form. It posts to the extension's controller unchanged:
form_keyfrom the form key store, sent as a form field, as the KO form did.X-Requested-With: XMLHttpRequest, so controllers that checkisAjax()answer JSON.- The reCAPTCHA token:
data-recaptcha="vendor_product_question"on the form draws the widget, and the handler readsg-recaptcha-responsesynchronously and sends it along (see Forms you submit yourself). The extension's observer validates it as before. - The message from the JSON response is shown with the messages store; it is already translated by the controller.
- After a success, the sections from
sections.xmlare reloaded.
vue
<!-- Questions.vue -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Link, router } from '@inertiajs/vue3'
import { useCustomerDataStore } from 'OOCS_LynxTheme/stores/global/customerDataStore'
import { useFormKeyStore } from 'OOCS_LynxTheme/stores/global/formKeyStore'
import { useMessagesStore } from 'OOCS_LynxTheme/stores/global/messagesStore'
type Pending = { product_id: number; question: string }
const props = withDefaults(defineProps<Lynx.VendorProductQuestions>(), {
_key: '',
enabled: undefined,
product_id: 0,
allow_guest: false,
items: () => [],
urls: () => ({ post: '', login: '' }),
sections: () => [],
labels: () => ({}),
})
const customerData = useCustomerDataStore()
const formKey = useFormKeyStore()
const messages = useMessagesStore()
onMounted(() => {
if (props.lazy && props.enabled === undefined && props._key) {
router.reload({ only: [props._key], preserveUrl: true })
}
})
const canAsk = computed(() => props.allow_guest || customerData.isLoggedIn)
// The extension's own section, per visitor, so never part of the cached page
const pending = computed(() =>
((customerData.section('product-questions')?.pending ?? []) as Pending[]).filter(
p => p.product_id === props.product_id
)
)
const loginText = computed(() => (props.labels.loginRequired ?? '').split('%1'))
const nickname = ref('')
const question = ref('')
const error = ref('')
const busy = ref(false)
onMounted(() => {
nickname.value ||= customerData.displayName
})
async function submit(event: SubmitEvent) {
const form = event.currentTarget as HTMLFormElement
// Read now: the reCAPTCHA widget resets once the submit event is over
const captcha = (form.elements.namedItem('g-recaptcha-response') as HTMLTextAreaElement | null)?.value
error.value = question.value.trim() && nickname.value.trim() ? '' : (props.labels.required ?? '')
if (error.value || busy.value) return
busy.value = true
try {
const response = await fetch(props.urls.post, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
form_key: formKey.ensure(),
product_id: String(props.product_id),
nickname: nickname.value,
question: question.value,
...(captcha ? { 'g-recaptcha-response': captcha } : {}),
}),
})
const result: { success?: boolean; message?: string } = await response.json()
messages.addServerMessage(result.success ? 'success' : 'error', result.message)
if (result.success) {
question.value = ''
await customerData.reload(props.sections)
}
} catch {
messages.add('error', props.labels.error ?? '')
} finally {
busy.value = false
}
}
</script>
<template>
<div v-if="enabled === undefined" class="grid w-full gap-3" aria-busy="true">
<span v-for="n in 3" :key="n" class="l-skeleton block h-4 w-full"></span>
</div>
<section v-else-if="enabled" class="grid w-full gap-8">
<h2 class="text-lg font-semibold text-base-content">{{ labels.title }}</h2>
<p v-if="!items.length" class="text-base-content/70">{{ labels.empty }}</p>
<ul v-else class="divide-y divide-base-300">
<li v-for="item in items" :key="item.id" class="grid gap-2 py-4">
<p class="font-semibold text-base-content">{{ item.question }}</p>
<p class="text-sm text-base-content/60">
{{ (labels.askedBy ?? '').replace('%1', item.nickname).replace('%2', item.date) }}
</p>
<p v-if="item.answer" class="border-s-(length:--border) border-base-300 ps-4 text-base-content/70">
<span class="font-semibold text-base-content">{{ labels.answer }}:</span> {{ item.answer }}
</p>
</li>
</ul>
<div v-for="p in pending" :key="p.question" class="l-alert">
<span class="font-semibold">{{ labels.pending }}:</span> {{ p.question }}
</div>
<form
v-if="canAsk"
:action="urls.post"
data-recaptcha="vendor_product_question"
novalidate
class="grid gap-4"
@submit.prevent="submit"
>
<h3 class="text-lg font-semibold text-base-content">{{ labels.ask }}</h3>
<label class="grid gap-1">
<span class="text-sm font-semibold text-base-content">{{ labels.nickname }}</span>
<input v-model="nickname" name="nickname" class="l-input w-full" required />
</label>
<label class="grid gap-1">
<span class="text-sm font-semibold text-base-content">{{ labels.question }}</span>
<textarea v-model="question" name="question" rows="4" class="l-textarea w-full" required></textarea>
</label>
<p v-if="error" class="text-sm text-error" role="alert">{{ error }}</p>
<div data-recaptcha-slot></div>
<div>
<button type="submit" class="l-btn l-btn-primary" :disabled="busy">{{ labels.submit }}</button>
</div>
</form>
<p v-else class="text-base-content/70">
{{ loginText[0] }}<Link :href="urls.login" class="text-link">{{ labels.signIn }}</Link>{{ loginText[1] }}
</p>
</section>
<div v-else></div>
</template>What the component follows from the Lynx rules:
- Design settings. Colors, corners, borders and control sizes come from tokens (
text-base-content,border-base-300,border-(length:--border)) andl-controls (l-btn,l-input,l-textarea,l-alert,l-skeleton), so the block follows each design. See Which design setting reaches which class. - RTL. Logical classes (
border-s,ps-4) mirror on right-to-left store views. - One root element in every state, and no
windowordocumentduring render, so SSR and hydration work. - No text of its own. Every string comes from
labelsor from the server's JSON message.
If the controller answers a non-AJAX post with a redirect and flash messages instead of JSON, post with Inertia's router.post and forceFormData: true; Inertia follows the redirect and the messages show on the next page. See Pages and Controllers.
6. Customer data
Your SectionSourceInterface and sections.xml stay as they are. On the client:
| Luma | Lynx |
|---|---|
customerData.get('product-questions') observable | useCustomerDataStore().section('product-questions'), reactive |
customerData.reload(['product-questions'], true) | customerData.reload(['product-questions']) |
Global AJAX listener invalidates sections from sections.xml | No global listener. The provider sends the list from SectionInvalidation::getForPath(), and the component reloads it after its request |
customerData.invalidate([...]) | customerData.invalidate([...]) marks them stale for the next load |
Data in localStorage (mage-cache-storage) | IndexedDB, database lynx_customer_data. Scripts reading localStorage find nothing |
Also on the store: isLoggedIn, displayName, customer, cart, cartCount, wishlistCount. Import it from OOCS_LynxTheme/stores/global/customerDataStore.
SectionInvalidation matches exact route keys only (lowercase, no wildcards), plus the * entry. If your sections.xml relies on a wildcard, pass the section names to the component yourself.
7. Install and build
bash
bin/magento module:enable Vendor_ProductQuestionsLynx
bin/magento setup:upgrade
bin/magento lynx:theme:setup OOCS_LynxVue # adds the module to the build
bin/magento lynx:static:deploy
bin/magento lynx:compile # when the store uses compiled configuration
bin/magento cache:cleanName the Lynx themes the store actually builds. See Development Setup.
8. Tell merchants where to place it
A Luma block appeared on the product page as soon as the extension was installed. A component appears once the merchant places it: in the editor, open a product page, add a Tab to the product's Tabs, and drop Product Questions into it (or into Below Add to Cart). Put this step in your installation guide. Do not write merchants' product page layouts from a data patch: those layouts belong to their designs.
Pages
A Luma extension page (a listing, a form, an account tab) is a controller with its own layout XML. On a Lynx store the controller still runs, but its regular layout files are not read, so the page shows only the header, footer and whatever PageBuilder layout is saved for its full action name. Two ways to port it:
| Layout-driven page | Page component from the controller | |
|---|---|---|
| How | Keep the controller as is; build the content from components | The controller calls InertiaService::setPage() with a page component and props |
| Merchants | Design it in the editor | Cannot change it |
| Header and footer | From the design | Not sent; the page stands alone |
| Fits | Anything shoppers browse | A standalone screen outside the store's frame |
Prefer the layout-driven page. Both are described in Pages and Controllers.
Checklist for any page
lynx_<handle>.xml. Copy what belongs in<head>and page behavior from the Luma layout file: title, robots,<update handle="lynx_no_cacheable"/>. Leave out thelayout="…"attribute.- Full page cache. A Luma page kept out of the cache by a
cacheable="false"block, or by<update handle="customer_account"/>, loses that on Lynx, because those regular layout files are not read. Addlynx_no_cacheableto thelynx_file of every page with personal data, or one customer's page is served to the next visitor. - Components for the content, with providers that call the extension's services.
- An entry in the editor's page list, so merchants can find and design the page.
- A starting layout in a data patch, so the page has content before anyone opens the editor.
An account page
A tab in the customer account (here My Product Questions at /productquestions/my/index) is a section component inside the shared My Account frame, plus a sidebar entry, an editor page and a starting layout. The full walkthrough, with this extension as the example, is on My Account Tabs.
A public listing page (/productquestions/index/index, all questions of the store) works the same way without the frame: a lynx_ file for the title and meta, a component with a provider, a pages entry without sample_data (the preview opens the real route), and a starting layout.
Checkout extensions
On a Lynx store checkout/index/index is the Lynx checkout. Checkout layoutProcessor plugins, checkout_index_index.xml, checkoutConfig JavaScript mixins and KnockoutJS checkout components do not apply. The quote, totals collectors, payment and shipping carriers, and the place-order APIs are Magento's own and keep working.
| Your extension adds | On Lynx |
|---|---|
| A payment method with its own form, button or redirect | A renderer in the paymentMethodRenderers pool. Offline methods show without one. See Payment Methods |
| An express button (wallet) on the cart or checkout | A component in the express_checkout group, placed in the Express Checkout slot. See Express checkout buttons |
| A shipping method that needs extra input (pickup point, delivery date) | A renderer under the shipping rate, with validate, data and summary hooks. See Shipping Methods |
| Address fields | Set in Stores › Attributes › Customer Address Fields, stored in attribute metadata. See Address form fields |
| Content beside the checkout or cart (trust badges, notes, upsells) | A component placed in Below Order Summary of the checkout, or Below Items / Below Summary of the cart |
| A tracking script for checkout steps or the purchase | A tracker on the analytics bus (begin_checkout, add_shipping_info, add_payment_info, purchase). See Forms and Analytics |
OOCS_LynxMSI is a complete example of bridging a core checkout feature: a shipping renderer that searches stores through Magento's REST API, and a plugin that writes the pickup location to the quote. See In-Store Pickup, step by step.
When not to port yet
Some pages are not worth porting at first, such as a rarely used popup or a complex multi-step flow. Page Fallback renders chosen pages with a standard Magento theme while the rest of the store stays on Lynx:
- In Stores › Configuration › Lynx › Theme Settings › Page Fallback, the merchant turns on Enable Fallback, picks a Fallback Theme (a Magento theme, not a Lynx one) and adds the full action names to Fallback Routes, one per line. A trailing
*matches by prefix:productquestions_*. - Those pages then render with the fallback theme, so the extension's own layout XML, templates and JavaScript work as on Luma.
What the merchant and shoppers see:
- The page has the fallback theme's header, footer and styles, not the Lynx design.
- Links from the Lynx storefront to it turn into full page loads; going back to a Lynx page loads Lynx again.
- The fallback theme's static content must be deployed for the store's locales.
Fallback Routes is a single configuration value that already holds Lynx's defaults, so do not set it from your module's config.xml; that would replace the list. List the line to add in your installation guide. See Page Fallback.
Page Fallback applies to whole pages. A block on a Lynx page (the product page, the cart) cannot fall back; port it as a component.
PHP that behaves differently on Lynx
Most server code needs no branch. When it does, for example an observer that injects HTML into the response body, or work that only feeds a Luma block:
| Question | Use |
|---|---|
| Does the current request run a Lynx theme? | OOCS\LynxTheme\Helper\Theme::isLynxTheme() |
| Does a given store view run a Lynx theme (cron, emails, admin)? | OOCS\LynxComponent\Helper\ThemeIdentity::isLynxForStore($storeId) |
Both count a theme as Lynx when its code, or the code of a parent, starts with OOCS/Lynx. Put such branches in the compatibility module, where the Lynx classes exist. Code inside the extension, which must not depend on Lynx, can test the theme chain of Magento\Framework\View\DesignInterface::getDesignTheme() for the same prefix.
Testing checklist
Test every ported piece on a Lynx store view with:
- [ ] Full page load of the page, then Inertia navigation to it from another page (click a link, then use Back). Both must show the same content; data loaded only on full page loads points to code in a
lynx_layout file that belongs in a provider. - [ ] SSR on for the route: the server-rendered markup must match the browser render, with no hydration warnings in the console. See Server-side Rendering.
- [ ] Every theme the store builds: Lynx Vue and its child themes.
- [ ] RTL: a store view with an Arabic, Persian or Hebrew locale, or
document.documentElement.dir = 'rtl'. - [ ] Design settings: in the editor preview, change Button color, Corners and Border width. Everything in your component that should follow must change.
- [ ] Guest and signed in: sign-in prompts, nickname prefill, customer data after sign-in and sign-out, the section reload after a post.
- [ ] Full page cache on: open the page as one customer, then as a guest and as another customer. No personal data may appear in a cached page, and saving the extension's entity in the admin must refresh it.
- [ ] Lazy option: with Load when shown on, inside a Tab; with the feature disabled in configuration, the tab disappears.
- [ ] Editor preview: the component in the picker on the right pages and slots, with a sample product on product pages and sample data on
sample_datapages. - [ ] reCAPTCHA with each type the merchant can choose for your key, and with none.
- [ ] Translations: a store view in another locale; no English text left in the component.
- [ ] After
lynx:compile: install on a store with compiled configuration and run it again, so newsections.xml,routes.xmlandevents.xmlentries are picked up.