Pagination and caching

Long lists and fast pages, without writing JavaScript

One call renders the first page of a collection on the server and fetches the rest over HTMX as the visitor scrolls or clicks. A second tag caches whatever is expensive to build and rebuilds it when the content behind it changes.

cms.render.loadMore() is a single call that paginates a collection. It renders the first page of items on the server, then appends an HTMX trigger that fetches the next page — when the visitor scrolls it into view, or when they click a button, depending on the trigger option. You supply a Twig template that renders one item and receives it as {{ object }}, and that same template renders the server-side page and every HTMX-loaded page after it.

Rendering the first page server-side matters for search engines, so load: true does it for you rather than making you write the for-loop. The rest of the options are the ones a listing needs: limit, sort, include and exclude filters, search, buttonLabel, and empty — HTML to show when the filters match nothing, decided by a lightweight count query rather than by rendering and finding out.

There are two placements. The default drops a self-replacing sentinel inside the content container. cms.render.loadMoreButton() emits a button you can put anywhere instead — a sidebar, a sticky footer — pointed at a container by CSS selector: items append into the target, an out-of-band swap rewrites the button's URL with the next offset, and the button removes itself from the DOM when the data runs out. Both have Data View equivalents.

The other half of this page is the cache tag, which stores the rendered HTML of a block so its body is skipped entirely on the next request. Give it a key, an optional TTL, and a list of collection ids as tags; when any object in a tagged collection is created, updated or deleted, every fragment carrying that tag is invalidated on its own, with no clear function to call. Caching is bypassed for logged-in visitors unless a fragment is explicitly marked shared, skipped in devmode so you always see fresh output while editing, and falls back to a live render if a cache backend errors.

Together they handle the two ways a listing page gets slow: too many items in the HTML, and too much work per request. A 900-post archive ships twelve posts and a cached sidebar, then streams the rest as the reader scrolls.

What you get

First page on the server

Set load: true and the opening batch is rendered into the HTML, so crawlers and no-JS visitors see real content, not an empty container.

Scroll or click

The revealed trigger gives infinite scroll; the click trigger gives a labelled button. Same call, one option apart.

A button anywhere

loadMoreButton() targets a container by CSS selector, so the control can live in a sidebar or a fixed header instead of at the end of the list.

An empty state you control

When filters match zero items, a count query fires first and your own HTML is rendered instead of a trigger that would fetch nothing.

Fragments that expire themselves

Tag a cached block with a collection id and it rebuilds the next time an object in that collection changes. The TTL is only a backstop.

In practice

One snippet

<div id="blog-feed"></div>
{{ cms.render.loadMoreButton('blog', {
	target: '#blog-feed',
	template: 'blog/card.twig',
	limit: 10,
	load: true
}) }}

The button can live anywhere on the page; items append into #blog-feed, and the button removes itself once the collection runs out.

FAQ

Common questions

Do I have to write any JavaScript?

No. HTMX ships with Total CMS and is emitted by cms.assetsBody(), and the helpers write the trigger markup for you. If your layout skips the asset helpers, the control renders unstyled and clicking it fetches nothing.

Will search engines see the first page?

Yes, when you use load: true or render the first page yourself with a for-loop. Both produce identical output; load: true just removes the boilerplate.

What happens when a filter matches nothing?

Set the empty option and Total CMS runs a lightweight count query with the same filters. If nothing matches, your HTML is rendered inside a cms-no-results div instead of a trigger.

Can a cached fragment show a logged-in visitor someone else's content?

Caching is bypassed for authenticated requests by default, so a signed-in visitor always gets a fresh render. Only opt a fragment into shared caching when the markup is identical for everyone.