LOAD MORE (paged listings): a listing that grows on demand ("load more", next page, infinite scroll) is three parts that must line up — the field's envelope, the page-1 markup, and the fetch script. The envelope is the single contract: the renderer resolves page 1 from it AND the server derives every later page from the SAME envelope, so there is nothing else to configure.
- THE FIELD — a
kind:'reference'field with reference_type:"collection". Its stored VALUE is the ENVELOPE, e.g. {"type":"collection","collection":"recipes","limit":12,"order_by":{"title":"asc"},"query":{"category":"soup"},"ref":"/data/{locale}/recipes"}.
- Set
limitto the page size and ALWAYS setorder_by— paging without an explicit stable order skips/repeats documents.order_bymust name a field the schema declares and EVERY document populates — the sort key doubles as a filter (documents missing it are silently excluded; a field no document has returns an empty page 1 with no error). querynarrows the listing; later pages inherit it automatically.queryon one field PLUSorder_by(or a range) on ANOTHER runs on a QUERY KEY on the collection. The common shapes are BUILT IN and need nothing:status+ newest (timestamp),status+ adatetimefield, the firstenumfield (alone or withstatus) + its date / newest; on productsstatus+ price / name / newest and the category / tag pages (readRule("data-collections") QUERY KEYS, readRule("commerce-products") LISTINGS). Only a shape beyond them — anumbersort likeprep_time, a second enum — needsregisterCollectionkeys: {"status_prep_time": ["status", "prep_time"]}, the equality fields first and the sort field last; declare it BEFORE shipping the listing: without a key the listing sorts in memory only while ≤ 500 documents match the filter; above that page 1 and every/contentpage answer400 query_key_required {collection, suggest}— declare exactlysuggestas the key, do not shrink the listing. A sort-only envelope (noquery) needs no key. Theaftercursor (the server'soffset) works unchanged under a key — nothing in the envelope or the script changes.- NEVER write resolved documents into the field. A materialized array cannot page, goes stale the moment content changes, and the load-more endpoint refuses it ("not a collection reference"). The renderer resolves the envelope at render time — that is the point.
- PAGE 1 — the template renders the resolved field as usual. Give the container the FIELD KEY and every card its doc id, so the script knows what to ask for and where it stopped:
<div class="rec-grid" data-field="recipes_list">
{{#each recipes_list}}<article class="rec-card" data-id="{{ this.id }}">…</article>{{/each}}
</div>
<button type="button" class="rec-more">{{t "list.load_more"}}</button>The button label is the same on every listing — a translations key, never a field each listing document must fill.
- MORE PAGES — POST
${DROP_BASE}/content/${DROP_LOCALE}with {path, field, after} (drop-core-server route; both globals are injected into every rendered page):
var grid = document.querySelector('[data-field]');
var btn = document.querySelector('.rec-more');
btn?.addEventListener('click', async function () {
var cards = grid.querySelectorAll('[data-id]');
var res = await fetch(window.DROP_BASE + '/content/' + window.DROP_LOCALE, {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({
path: location.pathname, // this page - the server reads ITS envelope
field: grid.dataset.field, // the reference field's key (dot-path for nested)
after: cards[cards.length - 1]?.dataset.id, // last id already shown
}),
});
if (!res.ok) return;
var body = await res.json();
body.documents.forEach(function (doc) { /* append a card; keep data-id="{doc.id}" */ });
if (!body.next) btn.hidden = true; // short/absent next = collection exhausted
});- The response is
{count, limit, documents, next?}—documentsare RAW collection docs (the same data page 1 rendered from; the one exception:kind:"link"fields inside them arrive already resolved to URL STRINGS, so a card script readsdoc.hrefas a plain string exactly as page 1 did),nextis the new cursor and is OMITTED when the collection is exhausted. - The request carries NO filters — collection/query/order_by/limit all come from the page's envelope, so the browser can never list anything the page doesn't already show; a
limitin the request may only SHRINK the page. - Build the appended card's markup to MATCH the {{#each}} block (same classes/fields), or the page visibly degrades after the first click.
- WHERE THE SCRIPT LIVES: when the listing is a library ELEMENT, the fetch script goes in that element's
js— it then loads only on pages placing the listing (readRule("elements") for the channel contract). When the listing is plain template markup, it goes in that template'sjs. Never a standalone store file loaded site-wide via headTags.
SMALL COLLECTIONS: when everything fits in one render (up to a few dozen docs), skip all of this — omit limit (or set it high) and filter/hide client-side. Paging is for collections that actually grow.