Migrating from Sprig

The pattern this replaces: a Sprig component running an element query per interaction, plus a hand-rolled loop counting facet values with another query per value.

It is not a bad pattern — it is the only one Craft offers out of the box — but it has a shape that gets worse exactly when a visitor is exploring fastest.

The trade, first

Caffeine serves what was published, not what is in the database this millisecond. Saves mark records stale and a debounced job republishes — 30 seconds by default.

For a product listing or an article archive that is invisible. For anything where a visitor must see their own write immediately, it is the wrong tool and Sprig is still the better one. Decide that before you start, not after.

What changes

SprigCaffeine
An element query per interactionOne artifact, compiled at index time
Facet counts by counting queriesCounts computed from postings lists
sprig, s-val: paramsReal <a href>s carrying the state
Component re-renders server-sideServer renders first paint, browser refines
Fresh data on every requestData as of the last publish

Rewriting the card

This is the only part that takes real thought. A Sprig component hands your card an element; Caffeine hands it a hit, which is a plain array of whatever the index marked payload.

{# before #}
<h3>{{ entry.title }}</h3>
<img src="{{ entry.image.one().url }}">
<a href="{{ entry.url }}">More</a>

{# after — every one of these is a key you added as payload #}
<h3>{{ hit.title }}</h3>
<img src="{{ hit.image.url }}">
<a href="{{ hit.url }}">More</a>

An asset in the payload arrives as { id, title, url, alt, width, height }, and a related entry as { id, title, slug, url } — enough to render a card without touching the database, which is the point.

If your card genuinely needs the element — a complicated Matrix render, an eager-loaded relation three levels down — load it from hit.objectID. That reintroduces a query per hit, so it is worth restructuring the payload instead, but it works and it is a reasonable first step.

Translating the query

Element queryIndex definition
.section('products')a source with container products
.brand('acme')attribute brand, role facet
.price('< 50')attribute price, numeric facet → rangeInput
.search('saw')attribute with role searchable
.orderBy('price asc')a sorting named price_asc
.relatedTo(category)attribute with path category.title, role facet

Relations are worth a note. Where Sprig does .relatedTo(x), Caffeine denormalises: the category’s title is copied into the record at build time. That is what makes the count instant, and it is why the dependency map exists — renaming the category marks every record that copied it as stale, so the artifact never serves a label that no longer exists.

Keeping Sprig for part of the page

Nothing stops you. Caffeine is a listing; Sprig is a component framework. A page can have a Caffeine listing and a Sprig add-to-cart button, and they will not interact — Caffeine only intercepts links pointing back at its own path.

An order that works

  1. Define the index. Use the record preview to check one real element maps the way you expect.
  2. Build and publish.
  3. Use the query playground to confirm the facets count the way the old page did.
  4. Port the template, keeping the old one alongside until the numbers match.
  5. Delete the Sprig component.

Step 3 is the one people skip and regret. Facet counts are where a definition’s mistakes show up, and comparing them against the page you already trust is the cheapest correctness check you will get.