Skip to content

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 pieceOn LynxWhere 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 PageBuilderCreating Components, Component Providers
KnockoutJS UI component, jQuery widget, x-magento-init / data-mage-init configThe same component, in Vue. The JSON config the widget received becomes provider propsFrontend 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 slotAdding 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 providerAdding 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 insteadLayout XML
Customer data section (sections.xml + SectionSourceInterface)Works unchanged. Read it with the customer data store; reload it after your own requestsCustomer data below
AJAX controller returning JSON or a redirectKeep it. Call it from the component with fetch or an Inertia router.postPosting to the controller below
Storefront page with its own route and layoutA layout-driven page merchants design, or a page component returned from the controllerPages below
Customer account tab (customer_account_navigation)An entry in the Lynx account sidebar and a section componentMy Account Tabs
Checkout layoutProcessor, checkout JS components, payment method rendererA payment or shipping renderer, or a component in a checkout slotCheckout extensions below
<script> / <link> in default.xml, verification tagsThe same instructions in lynx_default.xml, or a headless component in the Backstage zoneLayout XML, Forms and Analytics
Tracking script that listens to DOM events or KnockoutJS observablesA tracker on the analytics busForms and Analytics
reCAPTCHA key under recaptcha_frontend/type_for with its predispatch observerWorks unchanged. Add data-recaptcha="<key>" to the component's formForms and Analytics
i18n/<locale>.csv, including phrases used with $t() in JSSame CSV files. The provider translates the strings with __() and sends them as labelsTranslations
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 componentCMS content and widgets
Emails, admin, REST, GraphQL, cron, observers, plugins on models and servicesUnchanged

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_ProductQuestionsLynxInside the extension, guarded by <sequence>
Stores without LynxDo not install it; they get no Lynx code and no Lynx requirement in composer.jsonGet the Lynx files, and need the Lynx modules present for setup:di:compile to pass on classes that extend or reference them
BuildOnly stores that install it add its components to the theme bundleSame, but every store carries the source
ReleasesShip Lynx fixes without a new extension version, and the other way aroundOne 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.vue

Worked example: Product Questions ​

The Luma extension Vendor_ProductQuestions adds a Questions & Answers block to the product page:

  • catalog_product_view.xml adds the block Vendor\ProductQuestions\Block\Product\Questions to product.info.details, with a template listing approved questions from QuestionRepositoryInterface.
  • A KnockoutJS form posts product_id, nickname and question by AJAX to productquestions/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-questions lists the signed-in customer's questions awaiting moderation, and sections.xml invalidates it on productquestions/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 through PageCacheIdentities, 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's i18n/en_US.csv, and reuse Magento's own ("Sign In", "This is a required field.") where they fit.
  • Lazy loading follows the lazy convention, so the block can wait until its tab is opened.

3. Data the provider needs from Lynx ​

NeedUse
Current product or categorycurrent_product / current_category from Magento\Framework\Registry
Sections an action invalidatesOOCS\LynxComponent\Helper\SectionInvalidation::getForPath('<route>'), exact route from sections.xml
Full page cache tagsOOCS\LynxComponent\Model\PageCacheIdentities::add()
Text the merchant typed (_i18n settings)OOCS\LynxComponent\Helper\LocalizedText::get()
Image and link settingsMediaUrl 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_key from the form key store, sent as a form field, as the KO form did.
  • X-Requested-With: XMLHttpRequest, so controllers that check isAjax() answer JSON.
  • The reCAPTCHA token: data-recaptcha="vendor_product_question" on the form draws the widget, and the handler reads g-recaptcha-response synchronously 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.xml are 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)) and l- 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 window or document during render, so SSR and hydration work.
  • No text of its own. Every string comes from labels or 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:

LumaLynx
customerData.get('product-questions') observableuseCustomerDataStore().section('product-questions'), reactive
customerData.reload(['product-questions'], true)customerData.reload(['product-questions'])
Global AJAX listener invalidates sections from sections.xmlNo 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:clean

Name 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 pagePage component from the controller
HowKeep the controller as is; build the content from componentsThe controller calls InertiaService::setPage() with a page component and props
MerchantsDesign it in the editorCannot change it
Header and footerFrom the designNot sent; the page stands alone
FitsAnything shoppers browseA standalone screen outside the store's frame

Prefer the layout-driven page. Both are described in Pages and Controllers.

Checklist for any page ​

  1. 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 the layout="…" attribute.
  2. 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. Add lynx_no_cacheable to the lynx_ file of every page with personal data, or one customer's page is served to the next visitor.
  3. Components for the content, with providers that call the extension's services.
  4. An entry in the editor's page list, so merchants can find and design the page.
  5. 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 addsOn Lynx
A payment method with its own form, button or redirectA renderer in the paymentMethodRenderers pool. Offline methods show without one. See Payment Methods
An express button (wallet) on the cart or checkoutA 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 fieldsSet 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 purchaseA 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:

  1. 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_*.
  2. 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:

QuestionUse
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_data pages.
  • [ ] 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 new sections.xml, routes.xml and events.xml entries are picked up.