The Twig API
{% caffeine %} runs one search and exposes it. {% caffeineresults %} marks the part that changes.
The tags
{% caffeine 'products' as search %}
{{ search.searchBox({ placeholder: 'Search products' }) }}
{{ search.currentRefinements() }}
{{ search.refinementList('brand', { limit: 10 }) }}
{{ search.rangeInput('price') }}
{{ search.sortBy(['relevance', 'price_asc']) }}
{% caffeineresults %}
{{ search.stats() }}
{% for hit in search.hits %}
{% include '_cards/product' with { hit: hit } only %}
{% endfor %}
{{ search.pagination() }}
{% endcaffeineresults %}
{% endcaffeine %}
as search is optional and defaults to search. Options go in a with hash:
| Option | Default | What it does |
|---|---|---|
prefix | '' | Namespaces every query parameter, so two indexes on one page do not collide. |
path | current URL | What the facet links point back at. |
tag | div | Wrapper element. false omits it — and with it, the runtime. |
class, id | — | Put on the wrapper. |
runtime | true | false leaves the JavaScript off entirely; the links still work. |
htmx | false | Emit HTMX attributes instead of loading the runtime. |
There is also a function, for when a search belongs in a {% set %} rather than wrapped around half a template:
{% set search = caffeine('products') %}
Results
search.hits | The page of results. Each is the record’s payload plus objectID. |
search.nbHits | Total matching records. |
search.page / search.nbPages | Zero-based. |
search.from / search.to | One-based positions, for “showing 25–48 of 137”. |
search.query | The text query. |
search.isEmpty | Whether there were no results. |
search.processingTimeMS | How long the engine took. |
Facets
{% for bucket in search.facet('brand').buckets %}
<a href="{{ bucket.url }}" class="{{ bucket.isRefined ? 'is-on' }}">
{{ bucket.label }} ({{ bucket.count }})
</a>
{% endfor %}
A bucket carries value (the real, typed value — a boolean facet gives true, not "true"), key (its string projection, as it appears in a URL), label, count, isRefined and url.
Dates are formatted, booleans become Yes/No, and a hierarchical path is trimmed to its leaf — a menu already shows the ancestors as the path the visitor took to get there.
search.refinements is every active refinement flattened, each with the URL that removes it. search.hasRefinements says whether there are any.
URLs
Each returns the URL of the state that would result — never one that needs JavaScript to mean anything. All of them reset to the first page, because a visitor who narrows from 200 results to 3 while on page 7 should not be shown nothing.
{{ search.toggleUrl('brand', 'Acme') }}
{{ search.rangeUrl('price', 10, 50) }} {# omit both to clear it #}
{{ search.geoUrl('near', lat, lng, 8000) }} {# metres #}
{{ search.sortUrl('price_asc') }}
{{ search.pageUrl(2) }}
{{ search.queryUrl('cordless') }}
{{ search.clearUrl('brand') }} {# omit to clear everything #}
{{ search.canonicalUrl }} {# the unrefined URL #}
The URL format
State travels in a query string built to be read:
?q=cordless&brand=Acme,Globex&price=10..50&sort=price_asc&page=2
- Values are comma-separated. A literal comma or backslash in a value is backslash-escaped.
- Ranges use
.., open at either end. pageis one-based.- Anything at its default is omitted, so the unrefined state is a bare URL.
One exception, on the way in only: a plain HTML form posts one value per field and cannot produce price=10..50, so price_min and price_max are also accepted when parsing. Nothing ever encodes them, so the next click restores the canonical URL. That is what lets the range widget work without JavaScript.
Events
The wrapper dispatches caffeine:ready when an instance is wired and caffeine:render after a refinement is swapped in, both bubbling. It carries data-caffeine-busy while a request is in flight, and data-caffeine-hydrating while a cached page is catching up to a refined URL.