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
| Sprig | Caffeine |
|---|---|
| An element query per interaction | One artifact, compiled at index time |
| Facet counts by counting queries | Counts computed from postings lists |
sprig, s-val: params | Real <a href>s carrying the state |
| Component re-renders server-side | Server renders first paint, browser refines |
| Fresh data on every request | Data 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 query | Index 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
- Define the index. Use the record preview to check one real element maps the way you expect.
- Build and publish.
- Use the query playground to confirm the facets count the way the old page did.
- Port the template, keeping the old one alongside until the numbers match.
- 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.