Appearance
Pages and Controllers
Every storefront response on a Lynx store view is an Inertia.js page: a page component name, its props, the URL and the asset version. OOCS_LynxInertia produces it from a normal Magento controller, so adding a page starts with a route and a controller like on any store.
There are two kinds of pages:
| Layout-driven page | Controller-declared page | |
|---|---|---|
| Controller | Returns a result page, declares nothing | Calls InertiaService::setPage() with a page component and props |
| Content | The PageBuilder layout stored under the full action name | Your page component |
| Header, footer, backstage | From the design | Not sent |
| Editable by merchants | Yes, in the editor | No |
| Data | Component providers | Controller props |
Use layout-driven pages for anything shoppers see. Controller-declared pages suit standalone screens that stand outside the store's frame, such as a password prompt.
Adding a layout-driven page
This example adds an FAQ page at /faq in a module Vendor_Faq.
1. Route and controller
xml
<!-- etc/frontend/routes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="faq" frontName="faq">
<module name="Vendor_Faq"/>
</route>
</router>
</config>php
<?php
declare(strict_types=1);
namespace Vendor\Faq\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $pageFactory,
) {
}
public function execute(): Page
{
$page = $this->pageFactory->create();
$page->getConfig()->getTitle()->set(__('Frequently Asked Questions'));
return $page;
}
}That is all the controller does. Because it declares no page, OOCS_LynxPageBuilder builds one for it: the page component OOCS_LynxTheme::Default, with a layout prop holding the content zone stored under the request's full action name, here faq_index_index, plus the design's header, footer and backstage zones. Each node is resolved by its component's provider.
Until the design has a layout for that path, the page renders with the store's header and footer and an empty content area.
2. Layout handle (optional)
On a Lynx store view only layout files prefixed with lynx_ are read. Use view/frontend/layout/lynx_faq_index_index.xml for the page title, robots meta, or the page flags:
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">
<head>
<title>Frequently Asked Questions</title>
</head>
</page>3. Make the page editable
The editor's page switcher lists the pages declared in the pages argument of OOCS\LynxPageBuilder\Model\PreviewPagebuilder. Add yours in etc/di.xml (global scope: both the admin editor and the storefront preview read it):
xml
<type name="OOCS\LynxPageBuilder\Model\PreviewPagebuilder">
<arguments>
<argument name="pages" xsi:type="array">
<item name="vendor_faq" xsi:type="array">
<item name="sort_order" xsi:type="number">65</item>
<item name="label" xsi:type="string">FAQ</item>
<item name="path" xsi:type="string">faq_index_index</item>
<item name="route" xsi:type="string">faq/index/index</item>
</item>
</argument>
</arguments>
</type>| Key | Meaning |
|---|---|
label | Name in the page switcher |
path | Where the layout is stored: the full action name of the page |
route | Storefront route the preview opens ('' for the home page) |
sample | Appends a sample entity of the previewed store view to route: product (with type, a product type), category or search_term |
type | Product type for sample = product |
group | Groups pages under one entry in the switcher, such as Products or Account |
sort_order | Position in the switcher across all modules: home 10, 404 11, products 20, catalog 30–49, account 70, orders 75, checkout 80–89, other pages 90 and up. A group sits where its first page does. The lowest one opens when the editor URL names no page. |
sample_data | true for pages whose controller would send the editor away without context (an order that was just placed, a signed-in customer, a reset token). The preview then skips the controller and providers render sample data instead. |
Components can be limited to certain pages by path with available_on; see Creating Components.
4. Ship a starting layout (optional)
Layouts are rows of the lynx_pagebuilder_layout table, one per design and path, holding a JSON list of nodes. Lynx installs its own into the default design, which is read-only; merchants duplicate it, and copies inherit every path they have not saved themselves. A data patch can do the same for your page:
php
<?php
declare(strict_types=1);
namespace Vendor\Faq\Setup\Patch\Data;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use OOCS\LynxPageBuilder\Model\Design;
use OOCS\LynxPageBuilder\Setup\Patch\Data\CreateDefaultDesign;
class InstallFaqLayout implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly Json $json,
) {
}
public static function getDependencies(): array
{
return [CreateDefaultDesign::class];
}
public function getAliases(): array
{
return [];
}
public function apply(): self
{
$nodes = [[
'name' => 'default_group',
'component' => 'OOCS_LynxThemeComponent/Components/Group',
'label' => 'Group',
'visible' => true,
'props' => ['layout' => 'constrained', 'width' => 'site', 'padding_y' => 'sm', 'padding_x' => 'none'],
'slots' => ['default' => [[
'name' => 'default_heading',
'component' => 'OOCS_LynxThemeComponent/Components/Heading',
'label' => 'Heading',
'visible' => true,
'props' => ['text' => ['default' => 'Frequently Asked Questions'], 'level' => 'h1'],
]]],
]];
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable('lynx_pagebuilder_layout');
// Insert only: a row already under this path stays as it is
$saved = $connection->fetchOne(
$connection->select()->from($table, ['path'])
->where('design_id = ?', Design::DEFAULT_ID)
->where('path = ?', 'faq_index_index')
);
if (!$saved) {
$connection->insert($table, [
'design_id' => Design::DEFAULT_ID,
'path' => 'faq_index_index',
'value' => $this->json->serialize($nodes),
]);
}
return $this;
}
}Each node has name (the component's key in the registry), component (Module_Name/Path), label, visible, props and, for components with slots, slots. Settings left out of props take the component's defaults. Layouts are cached in the lynx_component cache type, so clean it after writing rows directly.
Supplying the content zone yourself
A module can supply zones before the design is read, as Lynx does for CMS pages that keep their layout on the page itself. Observe lynx_pagebuilder_layout_resolve_before and set the zone on the transport; zones already set there are not read from the design:
php
<?php
declare(strict_types=1);
namespace Vendor\Faq\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
class FaqContent implements ObserverInterface
{
public function execute(Observer $observer): void
{
if ($observer->getData('action') !== 'faq_index_index') {
return;
}
$transport = $observer->getData('transport');
$layout = $transport->getData('layout');
$layout['content'] = [/* nodes */];
$transport->setData('layout', $layout);
}
}lynx_pagebuilder_layout_resolve_after fires with the same arguments once every zone is read, for code that needs to see or adjust the final layout. Both run on the frontend: declare them in etc/frontend/events.xml.
Declaring a page component
A controller can answer with its own page component instead of a layout:
php
<?php
declare(strict_types=1);
namespace Vendor\Faq\Controller\Ask;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
use OOCS\LynxInertia\Service\InertiaService;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly InertiaService $inertiaService,
private readonly PageFactory $pageFactory,
private readonly RequestInterface $request,
) {
}
public function execute(): Page
{
$this->inertiaService->setPage(
'Vendor_Faq::Ask',
[
'heading' => __('Ask a question'),
'topics' => ['Shipping', 'Returns', 'Payment'],
],
$this->request->getRequestUri()
);
return $this->pageFactory->create();
}
}The first argument names the component: Vendor_Faq::Ask resolves to Vendor_Faq/Pages/Ask, the file Pages/Ask in the module's source folder. A name without ::, such as Ask, resolves to Pages/Ask in the theme's own lynx/src. Props must be JSON-serializable; pass translated strings from PHP with __().
vue
<!-- view/frontend/lynx/vue/src/Pages/Ask.vue -->
<script setup lang="ts">
defineProps<{
heading: string
topics: string[]
}>()
</script>
<template>
<div class="max-w-narrow mx-auto py-12 space-y-4">
<h1 class="text-3xl font-semibold text-base-content">{{ heading }}</h1>
<ul class="space-y-2">
<li v-for="topic in topics" :key="topic">{{ topic }}</li>
</ul>
</div>
</template>Page components render inside the page shell Layouts/Default, which holds the header, footer and backstage zones. A controller-declared page does not carry those zones, so on a full page load the shell has nothing to show there; after an Inertia visit from another page, the zones already on screen stay. Treat these pages as standalone, or build the page from a layout instead.
The module must be enabled and the theme set up again (bin/magento lynx:theme:setup) for its pages to be bundled. See Frontend Code for imports and styling.
Shared props
Shared props are merged into every page of the request, layout-driven or not. Call share() before the page is resolved; a before plugin on InertiaService::resolvePage(), declared in etc/frontend/di.xml, always runs in time:
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin;
use OOCS\LynxInertia\Service\InertiaService;
class ShareStoreHours
{
public function __construct(
private readonly \Vendor\Module\Model\StoreHours $storeHours,
) {
}
public function beforeResolvePage(InertiaService $subject): null
{
$subject->share('storeHours', $this->storeHours->getToday());
return null;
}
}Lynx shares these on its own:
| Prop | Value |
|---|---|
auth.isLoggedIn | Whether the customer is signed in, from the HTTP context the full page cache varies on |
title | The document title, on Inertia responses; the client sets document.title from it |
bodyClass | The page-type class of <body> (catalog-product-view, cms-index-index…), on Inertia responses; the client applies it to #html-body |
Shared values go into the full page cache with the page, so share only what is the same for every visitor in the same cache variation. Per-customer data belongs in customer-data sections, which the client loads separately.
vue
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3'
const page = usePage<{ auth?: { isLoggedIn?: boolean } }>()
</script>
<template>
<span v-if="page.props.auth?.isLoggedIn">…</span>
</template>Deferred and optional props
Wrap a callback with defer() to leave a prop out of the first response. The client requests it right after the page appears. Props in the same group are fetched in one request; different groups are fetched in parallel.
php
$this->inertiaService->setPage('Vendor_Faq::Ask', [
'heading' => __('Ask a question'),
// fetched together after the page appears
'popular' => $this->inertiaService->defer(fn () => $this->getPopularQuestions()),
'recent' => $this->inertiaService->defer(fn () => $this->getRecentQuestions()),
// fetched in a request of its own
'stats' => $this->inertiaService->defer(fn () => $this->getStats(), 'secondary'),
// never sent until the page asks for it
'archive' => $this->inertiaService->optional(fn () => $this->getArchive()),
], $this->request->getRequestUri());An optional() prop is neither sent nor announced; only a partial reload that names it resolves it. Callbacks run only when their prop is resolved.
vue
<script setup lang="ts">
import { Deferred } from '@inertiajs/vue3'
defineProps<{ popular?: string[] }>()
</script>
<template>
<Deferred data="popular">
<template #fallback>
<div class="l-skeleton h-24 rounded-box" />
</template>
<ul>
<li v-for="question in popular" :key="question">{{ question }}</li>
</ul>
</Deferred>
</template>On layout-driven pages, components defer their data through their provider instead. See Component Providers.
Partial reloads
A partial reload asks the server again for some props only, without changing the page:
ts
import { router } from '@inertiajs/vue3'
router.reload({ only: ['archive'] })The server resolves just the named props, including optional ones. A component on a layout-driven page receives a _key prop when its provider defers its data, and reloads its own data with router.reload({ only: [props._key] }).
Navigation
Use Inertia's Link and router for links between storefront pages. Only the next page's data is fetched; the header, footer and client state stay in place.
vue
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3'
const search = (q: string) => router.get('/catalogsearch/result/', { q })
</script>
<template>
<Link href="/faq">FAQ</Link>
</template>Lynx adjusts navigation in a few ways:
- Cache keys. The client adds a
_lynxquery parameter (and_lynx_onlyfor partial reloads) to every Inertia GET, so the full page cache stores the JSON apart from the HTML. The server strips them from the URL it returns, so they never reach the address bar. - Other origins. A
Linkto another site,mailto:ortel:is followed by the browser. - Full page loads. Pages handed to a standard theme by Page Fallback, and the checkout, answer an Inertia visit with
409and anX-Inertia-Locationheader, and the browser loads them in full. A plain<a>saves that round trip for links you know leave Lynx. - Focus. After a visit to a different path, focus moves to
<main id="page-content">, so keyboard and screen-reader users start at the new content. Visits that only change the query string (filters, sorting, paging) keep focus where it is.
Forms
Magento controllers read $_POST and check the form key. Inertia sends JSON by default, so post with forceFormData: true and include form_key from the form key store, which keeps the form_key cookie in place:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
import { useFormKeyStore } from 'OOCS_LynxTheme/stores/global/formKeyStore'
const formKey = useFormKeyStore()
const question = ref('')
const busy = ref(false)
function submit() {
busy.value = true
router.post(
'/faq/ask/post',
{ form_key: formKey.ensure(), question: question.value },
{ forceFormData: true, onFinish: () => (busy.value = false) }
)
}
</script>
<template>
<form @submit.prevent="submit">
<textarea v-model="question" class="l-textarea w-full" />
<button class="l-btn l-btn-primary" :disabled="busy">Send</button>
</form>
</template>Inertia's useForm works the same way when you pass forceFormData: true and add form_key to its data.
The POST controller is an ordinary Magento action: validate, add a message with the message manager, and return a redirect. Inertia follows the redirect as a visit, and the messages left in the mage-messages cookie are shown on the next page. Check the form key in the controller: Magento's CSRF check skips requests sent with X-Requested-With, which Inertia sends.
php
<?php
declare(strict_types=1);
namespace Vendor\Faq\Controller\Ask;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Framework\Controller\Result\RedirectFactory;
use Magento\Framework\Data\Form\FormKey\Validator as FormKeyValidator;
use Magento\Framework\Message\ManagerInterface;
class Post implements HttpPostActionInterface
{
public function __construct(
private readonly RequestInterface $request,
private readonly ManagerInterface $messageManager,
private readonly RedirectFactory $redirectFactory,
private readonly FormKeyValidator $formKeyValidator,
) {
}
public function execute(): Redirect
{
$question = trim((string) $this->request->getParam('question'));
if (!$this->formKeyValidator->validate($this->request)) {
$this->messageManager->addErrorMessage(__('Invalid Form Key. Please refresh the page.'));
} elseif ($question === '') {
$this->messageManager->addErrorMessage(__('This is a required field.'));
} else {
// save the question
$this->messageManager->addSuccessMessage(__('Thank you for your question.'));
}
return $this->redirectFactory->create()->setPath('faq');
}
}A classic <form method="post"> without Inertia also works and reloads the page; Lynx's own registration form posts that way. The sign-in form posts with fetch to core's customer/ajax/login. For reCAPTCHA on a form, see Forms and Analytics.
Title and head
The title comes from Magento's page config, as on any page: $page->getConfig()->getTitle()->set() in the controller, or <title> in a lynx_ layout file. A full page load prints it in <title>; an Inertia response carries it in the shared title prop, and the client applies it on navigation. Other head content (meta robots, canonical links) is part of the HTML and applies to full page loads.
Page flags
The mount point is the block lynx.inertia. Its arguments, set in a lynx_ layout file, change how the page is delivered:
| Argument | Effect |
|---|---|
ssr = false | Never render this page on the server, even when its route is in the SSR list |
encrypt_history = true | Encrypt the page's state in browser history (needs HTTPS); for pages with personal data |
clear_history = true | Discard previously encrypted history entries, so Back asks the server again; for the page after sign-out |
sri = true | Add Subresource Integrity hashes and an import map for the bundle's chunks |
Add <update handle="lynx_no_cacheable"/> to keep a page out of the full page cache. A private page combines both:
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">
<update handle="lynx_no_cacheable"/>
<head>
<meta name="robots" content="NOINDEX,NOFOLLOW"/>
</head>
<body>
<referenceBlock name="lynx.inertia">
<arguments>
<argument name="ssr" xsi:type="boolean">false</argument>
<argument name="encrypt_history" xsi:type="boolean">true</argument>
</arguments>
</referenceBlock>
</body>
</page>A controller can also call $inertiaService->encryptHistory() or $inertiaService->clearHistory() directly.
Asset versioning
The Inertia asset version is Magento's static content deployment version, the number in every /static/version<n>/ URL. When a deployment changes it, the next Inertia visit from an open tab gets 409 with X-Inertia-Location, and the browser reloads the page instead of running old JavaScript against new props. InertiaService::setVersion() overrides the version if you need your own scheme.