Skip to content

SSR Internals ​

This page explains how server-side rendering works under the hood: how Magento talks to the renderer, which requests are rendered, and what a component needs to do to render correctly on the server. To install a renderer, see Running the renderer; the admin settings are in the SSR guide.

In one sentence: SSR turns the page JSON that Lynx prints in <body> into HTML on the server. Without it, the body holds the page object (<script type="application/json" data-page="lynx">) and an empty <div id="lynx">, and the browser builds the markup after the JavaScript loads. With it, the renderer runs the same Vue code on that JSON and Magento prints the resulting HTML inside the mount point; the browser then hydrates it. The <head> is printed by Magento in both cases. The reasons to turn it on are on Server-side Rendering.

html
<head>
  <title>Men</title>
  <meta name="description" content="…">
</head>
<body>
  <script type="application/json" data-page="lynx">
    {"component": "OOCS_LynxTheme/Pages/Default",
     "props": {"layout": {"header": […], "content": […], "footer": […]}}}
  </script>
  <div id="lynx"></div>  <!-- empty until the JavaScript has run -->
</body>
html
<head>
  <title>Men</title>
  <meta name="description" content="…">
</head>
<body>
  <script type="application/json" data-page="lynx">{…the same JSON…}</script>
  <div id="lynx" data-server-rendered="true">
    <header class="page-header">…menu, search, cart…</header>
    <main id="page-content">
      <h1>Men</h1>
      …the product grid, already in HTML…
    </main>
    <footer class="page-footer">…</footer>
  </div>
</body>

Running the renderer ​

The renderer runs as a Node.js process next to Magento or as a Cloudflare Worker; the SSR guide compares the two. Once it runs, SSR is switched on in the admin, as described in Turning it on.

Node.js process ​

Requires Node.js 22 or newer on the same server as Magento, or in the same private network.

  1. Build the renderer together with the theme, from the Magento root folder:

    bash
    bin/magento lynx:static:deploy --ssr

    It builds the themes set up by lynx:theme:setup (see Installation); name one, such as OOCS_LynxVue, to build only that theme.

  2. Start the renderer from the theme's folder:

    bash
    cd app/design/frontend/OOCS/LynxVue/lynx
    npm run ssr

    It listens on port 13714 by default. Set LYNX_SSR_PORT to use another port, and LYNX_SSR_CLUSTER=1 to run one worker per CPU core on busy stores.

  3. Keep it running with your usual process manager, such as systemd, PM2 or Supervisor, so it starts again after a restart or a crash.

  4. In the admin, set Renderer URL to http://127.0.0.1:13714. See Turning it on.

WARNING

The renderer has no login of its own. Keep its port closed to the internet, for example by listening on 127.0.0.1 with LYNX_SSR_HOST=127.0.0.1 or with a firewall rule.

Cloudflare Worker ​

Cloudflare Workers run your renderer on Cloudflare's network, so your hosting only needs to run Magento. Use it when your server cannot run Node.js, or when you would rather not look after the renderer yourself: Cloudflare keeps it running and adds capacity on its own when traffic surges, such as during a sale, so pages keep rendering quickly without any servers to size or monitor.

Use a paid Workers plan

Rendering a page takes more processing time than the free Workers plan allows for each request. Subscribe to the Workers Paid plan on your Cloudflare account. It costs a few dollars a month and covers millions of requests.

The steps below run on any computer with Node.js 22 or newer, such as your developer's machine or your deployment pipeline, not necessarily on the Magento server.

  1. Build the Worker from the theme's folder:

    bash
    cd app/design/frontend/OOCS/LynxVue/lynx
    npm run build:ssr:worker
  2. Sign in to Cloudflare once. This opens a browser window:

    bash
    npx wrangler login

    On a machine without a browser, create an API token in Cloudflare under My Profile › API Tokens with the Edit Cloudflare Workers template, and pass it with your account ID instead:

    bash
    CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<account id> npm run deploy:ssr:worker
  3. Deploy:

    bash
    npm run deploy:ssr:worker

    The first time, Cloudflare asks you to pick a workers.dev subdomain. The command prints the Worker's address, such as https://lynx-ssr.<your-subdomain>.workers.dev.

  4. In the admin, set Renderer URL to that address (see Turning it on).

    Each render makes a short trip from your server to Cloudflare and back. Workers start in milliseconds, so the default Timeout is usually enough. If pages often fall back to being drawn in the browser, raise it. Those fallbacks are logged in var/log/system.log as Lynx SSR: renderer did not answer.

Redeploy the Worker each time you update Lynx or rebuild the theme, so the renderer and the store stay on the same version. To restrict who can call the Worker, put Cloudflare Access or a WAF rule in front of it.

In production ​

  • Rebuild and restart the Node.js renderer, or redeploy the Worker, every time you rebuild the theme. The renderer and the browser bundle must come from the same build, or hydration will not match.

  • Run the Node.js renderer under a process manager. A systemd unit, for example:

    ini
    [Unit]
    Description=Lynx SSR renderer
    After=network.target
    
    [Service]
    User=www-data
    WorkingDirectory=/var/www/magento/app/design/frontend/OOCS/LynxVue/lynx
    Environment=LYNX_SSR_HOST=127.0.0.1
    Environment=LYNX_SSR_CLUSTER=1
    ExecStart=/usr/bin/node --enable-source-maps ./dist/ssr/nodejs/server.mjs
    Restart=always
    
    [Install]
    WantedBy=multi-user.target
  • A Worker URL is public. Protect it with Cloudflare Access or a WAF rule if you do not want others to render pages on your account.

How a request is rendered ​

Browser ── GET /some-page ──▶ Magento (FPC miss)
                               │  SsrGate: SSR on? GET? not an Inertia visit? route and customer group match?
                               │  resolve the Inertia page object
                               │  POST {renderer}/render  { ...page, storeConfig }
                               ▼
                             Renderer (Node.js or Cloudflare Worker)
                               │  renders the page with the theme's own Vue code
                               ▼
                             { head: [...], body: "<script data-page…><div id=\"lynx\" data-server-rendered…>…</div>" }
                               │
Magento prints body in place of the empty mount point, the full page cache stores the result
Browser downloads the same JavaScript and hydrates the markup
  • The renderer is stateless and knows nothing about Magento. It receives the page object that would otherwise be printed as JSON in the page, and returns HTML.
  • The result goes into the full page cache with the rest of the page, so a page is rendered once per cache miss, not once per visitor.
  • Only the first, full-page load is rendered. Later navigation is an Inertia visit answered with JSON and drawn in the browser, as without SSR.

When a page is rendered ​

OOCS\LynxInertia\Service\SsrGate::isEnabled() decides, per request. All of these must hold:

ConditionSource
SSR is enabled for the store viewlynx_ssr/general/enabled
The request is a GET and not an Inertia visit (no X-Inertia header)request
The full action name matches a line of Routes (fnmatch patterns such as catalog_*, one per line)lynx_ssr/general/routes
The visitor's customer group is selected in Customer Groups (read from the HTTP context the full page cache varies on)lynx_ssr/general/customer_groups
The request is not a PageBuilder design previewplugin OOCS\LynxPageBuilder\Plugin\DisableSsrInPreview

All settings are read at store-view scope, so each store view can point at its own renderer. This is how store views on different themes each use the renderer built from their own sources.

Keeping a page out of SSR ​

A page's layout can switch rendering off by setting ssr to false on the lynx.inertia block. The page is then always drawn in the browser, even if its route matches Routes:

xml
<referenceBlock name="lynx.inertia">
    <arguments>
        <argument name="ssr" xsi:type="boolean">false</argument>
    </arguments>
</referenceBlock>

The argument can only switch SSR off. true does not render a route that the configuration does not select. Lynx sets it on the cart, the order success and failure pages, payment review pages and every customer account page: they are personal, not cached, and would cost a render on every request.

To take SSR away from a request based on your own condition, add an after plugin on SsrGate::isEnabled(), as the design preview does:

php
<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin;

use OOCS\LynxInertia\Service\SsrGate;

class DisableSsrForCampaign
{
    /**
     * @param SsrGate $subject
     * @param bool $result
     * @return bool
     */
    public function afterIsEnabled(SsrGate $subject, bool $result): bool
    {
        return $result && !$this->isCampaignRequest();
    }

    private function isCampaignRequest(): bool
    {
        // Your condition
        return false;
    }
}

Register it in etc/frontend/di.xml.

The renderer contract ​

Both runtimes answer the same HTTP API. Magento appends the paths to Renderer URL, which holds only scheme, host and port.

RequestResponse
POST /render with the page as JSON200 { "head": string[], "body": string }
POST /render that fails to render500 { "error", "component", "url", "stack" }
GET /health200 { "status": "OK" }. Test Connection in the admin calls this.
Any other path200 { "status": "NOT_FOUND" }

The request body is the Inertia page object with one extra key, storeConfig, holding the store-scope configuration the components read through OOCS_LynxTheme/utils/storeConfig:

json
{
  "component": "OOCS_LynxTheme/Pages/Default",
  "props": { "layout": { "header": [], "content": [], "footer": [] } },
  "url": "/women/tops.html",
  "version": "…",
  "storeConfig": { "storeCode": "default", "currencyCode": "USD", "pageComponents": [] }
}

The renderer removes storeConfig before rendering, so it never becomes part of the page props.

Failures fall back to the browser ​

OOCS\LynxInertia\Service\SsrRenderer treats anything other than a 200 with a string body as "no SSR for this request". The page is then printed with an empty mount point and drawn in the browser, as if SSR were off. Nothing is shown to the visitor. Each case is logged as a warning in var/log/system.log:

Log messageCause
Lynx SSR: renderer did not answerConnection refused, DNS failure or timeout. The total time is capped by Timeout (ms) (minimum 100, default 2000); connecting is capped at 1 second or the timeout, whichever is lower.
Lynx SSR: renderer refused the pageA non-200 status, a body that is not JSON, or a NOT_FOUND answer (usually a Renderer URL with a path). The error field carries the renderer's message.
Lynx SSR: page cannot be encodedThe page props could not be encoded as JSON.

The head array is ignored: the <head> of the page is built by the Magento layout, so titles and meta tags come from Magento, not from components.

Since the contract is plain HTTP and JSON, any service that implements it can act as the renderer.

Two runtimes, one render module ​

The rendering code lives in the module OOCS_LynxTheme, as view/frontend/lynx/vue/src/ssrRender.ts. It uses neither Node.js nor browser APIs, so the same file runs in both runtimes. It:

  • builds the Inertia app once per process, with the same page map and default layout as the browser entry,
  • creates a fresh Pinia instance for every render, so one visitor's state never leaks into the next page,
  • runs renders one at a time within a process, because the store configuration is set per render.

Each theme adds two thin HTTP entries in its lynx/src/ folder:

RuntimeEntryBuildOutput
Node.jsssr.ts (Inertia's Node server)npm run build:ssrdist/ssr/nodejs/server.mjs
Cloudflare Workerssr-worker.ts (re-exports OOCS_LynxTheme/ssrWorker)npm run build:ssr:workerdist/ssr/worker/worker.mjs

Run the commands in the theme's lynx folder, for example app/design/frontend/OOCS/LynxVue/lynx. bin/magento lynx:static:deploy --ssr runs build:ssr together with the browser build.

Differences between the two builds:

  • Node.js targets Node 22 and bundles every dependency, so dist/ssr/nodejs runs without node_modules and can be copied to another host. Start it with npm run ssr, which adds --enable-source-maps so a failed render names the original .vue or .ts file.
  • Worker is built with ssr.target: 'webworker', so dependencies resolve to their browser-compatible versions. The build fails if a dependency only works with Node.js built-ins. It is one file without code splitting, since a Worker cannot load chunks from disk, and wrangler uploads its source map.

Node.js renderer environment variables:

VariableDefaultPurpose
LYNX_SSR_PORT13714Listening port.
LYNX_SSR_HOST0.0.0.0Listening address. Use 127.0.0.1 when PHP runs on the same host.
LYNX_SSR_CLUSTERunset1 forks one worker per CPU core. Without it, one process renders one page at a time.

The Node.js server also answers /shutdown, which stops the process. Like the rest of the renderer it has no authentication, so never expose the port publicly.

The Worker is configured in the theme's wrangler.toml. The Worker is named lynx-ssr. Renderer URL is the Worker's origin only, such as https://lynx-ssr.example.workers.dev, without /render.

Writing SSR-safe components ​

Every node component in a rendered route runs on the server first. The rules below keep it working there and keep hydration clean.

No browser globals at module scope ​

window, document, localStorage, navigator and the like do not exist in the renderer. Use them only in code that runs in the browser: onMounted, or event handlers. The body of <script setup> runs on the server, so it counts as module scope.

vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const width = ref(0)

// Wrong: runs during server render
// const width = ref(window.innerWidth)

onMounted(() => {
    width.value = window.innerWidth
})
</script>

The theme's ESLint configuration reports this with ssr-friendly/no-dom-globals-in-module-scope.

Render the same markup on both sides ​

The first browser render must produce the markup the server sent, or the framework reports a hydration mismatch and redraws the affected part.

  • Do not branch the rendered output on typeof window, the current time, random values or the viewport size. Render a neutral state first and change it after mount.
  • Customer data (login state details, cart, wishlist) is loaded in the browser after hydration. Render the guest or empty state first and let the store update it.
  • Read store configuration through config() from OOCS_LynxTheme/utils/storeConfig. On the server it returns the storeConfig sent with the request; in the browser it reads the JSON block in the page. Do not read the DOM for it.

No visitor state at module scope ​

A renderer process serves every visitor. A variable at module level keeps its value from one render to the next. Keep per-visitor state in stores (Pinia), which are created per render.

Data comes from providers, not from fetches ​

The server render does not call your endpoints. Everything a node needs for its first paint should arrive in its props from the PHP provider (see Providers). Renders in one process run one after another, so a slow await in a component delays every page waiting behind it.

Headless components ​

A component that renders nothing, such as a script loader, must still produce the same placeholder on both sides.

Do not leave <template> empty or holding only an HTML comment. The server compiles it to nothing while the browser renders a comment node, which shifts every node after it during hydration. Remove the <template> and declare the render function instead:

vue
<script setup lang="ts">
defineOptions({ render: () => null })
// browser-only work in onMounted
</script>

Hydration and lazy chunks ​

Server markup appears at once, but a node can only hydrate once its JavaScript chunk has loaded. If a node still waiting for its chunk receives an update (the page props being set at startup, a deferred prop arriving, customer data loading), the framework discards its server markup and renders it again from scratch, and the content flickers.

The theme prevents this: it requests the chunks of every zone at startup and hydrates each node as its chunk arrives. Node components read preview state through computed values, so replacing the page props does not re-render nodes that did not change.

If you override OOCS_LynxTheme/storefront.ts, where the theme's main.ts mounts the app, keep this behavior.

Verifying SSR output ​

  1. Check the renderer:

    bash
    curl -s http://127.0.0.1:13714/health
    # {"status":"OK",...}
  2. Flush the full page cache, then request a page as a guest and look for the marker Inertia adds to server-rendered markup:

    bash
    curl -s https://example.com/ | grep -o 'data-server-rendered="true"'

    No match means the page was drawn in the browser. Check that the route is in Routes, that the request is not a cached page from before SSR was turned on, and look in var/log/system.log for Lynx SSR: warnings.

  3. Read render errors where the renderer logs them: the Node.js process's standard error (with source-mapped stack traces), or npm run tail:ssr:worker for the Worker.

  4. Look for hydration mismatches in the browser console while developing, and compare the page with JavaScript disabled to see exactly what the server produced.