Guide 01 · Developer docs

Choosing how to build

Decide where the pages of a Core dna site are composed before writing code: on the platform, in your own application, or on the platform with a few pieces filled in by the browser. The decision tree, each shape in depth, multi-brand and languages, and a table and case studies to make the call.

L2
Unit 04 · Hybrid, the common case

Dynamic fragments

The pattern that keeps a personal page cacheable: a fragment template that switches caching off, a page layout that serves fragments by name, and a script that fetches them after the cached page has painted. The whole mechanism, in both engines, and how to check it works.

Who this is for. The theme developer and the JavaScript developer of any site with members, a cart or personal content, which is most of them. This page owns the pattern; other guides link here. After it you can take a personal element out of a cached page and deliver it on its own.

Why fragments

The platform stores pages rendered for visitors who are not signed in, and sends the stored copy before it has checked who is asking. Anything personal inside a cacheable page is therefore either the wrong visitor's, or the signed-out version shown to a member (What one personal element does). Switching the cache off for the whole page fixes that at the price of rendering every view from scratch.

A fragment is the alternative: the personal part is rendered on its own, never cached, and fetched by the browser after the cached page has painted. The page stays in the cache for everyone; only the fragment is rendered per visitor, and it is small.

The mechanism

  1. A page layout called, say, Fragment, whose template serves fragment templates by name.
  2. A page for each fragment. In the admin, a parent page such as /fragments, with a child page on the Fragment layout for each fragment: account, cart-count. Its slug is the fragment's name. Unpublishing the child switches the fragment off. Set each one's robots setting to Don't index and don't follow, which also keeps it out of the generated sitemap.
  3. A fragment template for each, which switches caching off and renders only the personal part.
  4. A placeholder in the cached page and a script that replaces it with the fragment.

The dispatcher

The Fragment layout's template looks the page's slug up in a list of known fragments and includes the one it names. The list matters in Smarty, where including a file that does not exist stops the page with an error, and it documents in one place which fragments the site has:

Template code templates/pages/pages/fragment.html
{$_response->preventCaching()}
{$fragments = ['account' => 'fragments/account.html', 'cart-count' => 'fragments/cart-count.html']}
{$name = $page->getSlug()}
{if isset($fragments[$name])}
  {include file=$fragments[$name]}
{/if}
{% do _response.preventCaching() %}
{% set fragments = {'account': 'fragments/account.html', 'cart-count': 'fragments/cart-count.html'} %}
{% set name = page.getSlug() %}
{% if fragments[name] is defined %}
  {{ include(fragments[name]) }}
{% endif %}

The fragment template

A fragment renders only the personal part, with no layout around it, and calls preventCaching() itself as well, so that it stays uncached whichever page includes it. The account fragment also carries the session's CSRF token for the site's scripts, as a data attribute, because a script inserted into a page with innerHTML does not run:

Template code templates/fragments/account.html
{$_response->preventCaching()}
{if $_currentUser->isLoggedIn()}
  <a href="/my-account">My account</a>
  <a href="/logout">Sign out</a>
  <span hidden data-csrf="{$_csrfToken|default:''}"></span>
{else}
  <a href="/login">Sign in</a>
{/if}
{% do _response.preventCaching() %}
{% if _currentUser.isLoggedIn() %}
  <a href="/my-account">My account</a>
  <a href="/logout">Sign out</a>
  <span hidden data-csrf="{{ _csrfToken|default('') }}"></span>
{% else %}
  <a href="/login">Sign in</a>
{% endif %}

The placeholder and the fetch

The cached page holds a placeholder with the signed-out version inside it, so the page is complete before the script runs and without JavaScript at all. It names the fragment it wants:

Template code In the header include
<div class="account-links" data-fragment="/fragments/account">
  <a href="/login">Sign in</a>
</div>
<div class="account-links" data-fragment="/fragments/account">
  <a href="/login">Sign in</a>
</div>
JavaScript src/js/fragments.js
import { setCsrfToken } from './api-client.js';

// Replace every placeholder with its fragment, after the cached page has painted.
export async function loadFragments() {
  const slots = document.querySelectorAll('[data-fragment]');
  await Promise.all([...slots].map(async (slot) => {
    const response = await fetch(slot.dataset.fragment, { credentials: 'same-origin' });
    if (!response.ok) return; // keep the placeholder: the page still works

    const holder = document.createElement('div');
    holder.innerHTML = await response.text();

    const token = holder.querySelector('[data-csrf]')?.dataset.csrf;
    if (token) setCsrfToken(token);

    slot.replaceChildren(...holder.childNodes);
    slot.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
  }));
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', loadFragments);
} else {
  loadFragments();
}

The token goes to the one module that sends requests (One client module), and the event lets other scripts wait for the fragment instead of racing it. The swap is invisible to most visitors, because most are not signed in and the placeholder already shows what they would get.

Fragments that take a parameter

A fragment that depends on a query parameter, such as a product id for a member price, still calls preventCaching(). If you ever make such a fragment cacheable, for example because its answer is the same for every visitor, make sure the parameter is part of the cache key. Reading it with $_request->getParameter('product') does that by itself; a parameter read any other way, through getQueryString() for example, has to be declared with $_response->addCacheVaryParameter('product'). Otherwise the page cache ignores it, and the first visitor's answer is served for every product.

Checking that it works

  • The fragment is never stored. Request it signed out and read the response headers: Cache-Control: no-store and X-Cache-Status: miss, no-store. Any other X-Cache-Status means the template is not switching the cache off.
  • The page has nothing personal in it. Sign in, load a cached page, and view the source rather than the inspector: the placeholder should hold the signed-out version, and no name, count or token.
  • It degrades. Turn JavaScript off: the page still works, showing the signed-out version.

A development environment stores no pages at all, so a page that works there can still show the wrong visitor's data on production. Check the headers on production, or on a staging environment that runs as production, before launch.

Go deeper

Was this page helpful?

Your answer goes to the docs team.