Search and Analytics Integration
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Antora 3.2.1, @antora/lunr-extension 1.0.0-alpha.13, Antora UI Default (clone at commit 0e38223a),
Pagefind 1.5.2 and DocSearch 5.1.2, and checked on 2026-10-26. Antora does not know what search or analytics are: it only
renders Handlebars templates with a UI model. Search and analytics therefore live in the UI, as markup in the header
partial, script tags in the footer or head partials, and values passed through the playbook. The Lunr extension is the
one search integration with a contract between the extension and the UI; Pagefind and Algolia DocSearch are plain
JavaScript widgets that the UI loads itself. The extension itself is covered in
Site Search.
Where search and analytics plug into a UI
The Default UI is built from small partials, and three of them matter here. All the snippets on this page are supplemental files, so they work with any bundle that has the same partial names, and the same files can be moved into a forked UI (see Supplemental UI and Building a custom UI).
| Partial | What it does in the Default UI |
|---|---|
|
Renders the navbar. When |
|
Loads |
|
Loads Google Analytics when the playbook defines the key |
|
Empty on purpose: it holds a comment inviting additional meta tags, so it is a good place for a stylesheet link. |
Source: Antora UI Default repository
The figure shows the three search options side by side. The only difference is who produces the index and who loads the script.
Satisfying the Lunr contract
The extension does three things when it is registered, and the UI has to cooperate with each (the behaviour below was read from the extension source and observed in a build of the Pocket Units sample):
-
On the
uiLoadedevent it setsenv.SITE_SEARCH_PROVIDERtolunr. The templates test that value, so the search field is rendered only when the extension is active. The extension removes the value again onbeforePublish. -
It adds
js/vendor/lunr.js,js/search-ui.jsandcss/search.cssto the UI catalog, unless a file with the same path already exists. For thesearch-scriptspartial it does the same: if the UI already haspartials/search-scripts.hbsthe UI copy is kept, otherwise the extension adds its own. -
On
beforePublishit writessearch-index.jsat the site root.
What a custom UI must therefore provide is small:
-
The header markup, guarded by
env.SITE_SEARCH_PROVIDER, containing an input with the idsearch-input. The script finds it withdocument.getElementById('search-input'), so a different id breaks the search silently. -
The footer include
{{> search-scripts}}. It can also be placed anywhere after the input, as long as it is rendered on every page that has the search field. -
Optionally, a checkbox inside an element with the id
search-fieldand the attributedata-facet-filter. The value iscomponent:followed by the component name, so that ticking it restricts results to the current component. -
No copy of
lunr.jsin the bundle.
The header partial
{{#if env.SITE_SEARCH_PROVIDER}}
<div class="navbar-item search hide-for-print">
<div id="search-field" class="field">
<input id="search-input" type="text" placeholder="Search the docs"{{#if page.home}} autofocus{{/if}}>
<label class="search-filter">
<input type="checkbox" data-facet-filter="component:{{page.component.name}}" checked> This product only
</label>
</div>
</div>
{{/if}}
Source: Antora Lunr Extension README
This snippet is the search block of the header-content partial plus the optional facet checkbox. It was built and run
in the Pocket Units sample: the input was enabled once the index loaded, and with the checkbox ticked a query returned
only pages of the pocket-units component. The first attempt used the bare component name as the value of
data-facet-filter; the search then returned no results for any query, because the script splits the value at the
colon into a field and a term. Use component: followed by the name.
The footer partial
<script id="site-script" src="{{{uiRootPath}}}/js/site.js" data-ui-root-path="{{{uiRootPath}}}"></script>
<script async src="{{{uiRootPath}}}/js/vendor/highlight.js"></script>
{{#if env.SITE_SEARCH_PROVIDER}}
{{> search-scripts}}
{{/if}}
This is the Default UI footer, shown because it is the model to follow: search-scripts is included only when a search
provider is active, so the same UI works with search on or off. The partial that the extension generates contains four
tags: lunr.js, lunr-languages.js (only when the languages option names languages other than English), search-ui.js
with the data attributes data-site-root-path, data-snippet-length and data-stylesheet, and an asynchronous load
of search-index.js. A UI that wants to control script order or add attributes can ship its own
partials/search-scripts.hbs; the extension then leaves it alone. In that case copy the four tags from the generated
partial in the built site rather than writing them from memory, because the search-ui.js data attributes are read by
the script.
Never bundle lunr.js
The README says a UI must not contain lunr.js. With the 1.0.0-alpha.13 build used here the observed behaviour was
different from a hard error: a placeholder file at js/vendor/lunr.js in the supplemental files was kept and the
extension logged, at info level, that the file already exists and was skipped. The built site then loaded the
placeholder in place of Lunr, so the search could not work, and nothing in the default log output (warnings and
errors) pointed at the cause. Keep the vendor file out of the UI and let the extension supply it, and run a build with
--log-level=info when search stops working after a UI change.
Replacing Lunr with Pagefind
Pagefind is not an Antora extension. It is a command that reads the generated HTML, writes a folder of index chunks and
widget files into the site, and is loaded by the page. The UI part is therefore a stylesheet link, a container element
and a script. Because the Lunr extension is not registered, env.SITE_SEARCH_PROVIDER is not set and the Default
UI header would not render a search field, so the supplemental header-content partial must render the container
without the condition.
Install it as a dev dependency, pinned:
npm i -D -E pagefind@1.5.2
Source: Pagefind documentation
The three supplemental partials, in a directory used as ui.supplemental_files: ./supplemental-ui:
<link rel="stylesheet" href="{{{siteRootPath}}}/pagefind/pagefind-component-ui.css">
<header class="header">
<nav class="navbar">
<div class="navbar-brand">
<a class="navbar-item" href="{{{or site.url siteRootPath}}}">{{site.title}}</a>
<div class="navbar-item search hide-for-print">
<pagefind-searchbox></pagefind-searchbox>
</div>
<button class="navbar-burger" aria-controls="topbar-nav" aria-expanded="false" aria-label="Toggle main menu">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
</header>
<script id="site-script" src="{{{uiRootPath}}}/js/site.js" data-ui-root-path="{{{uiRootPath}}}"></script>
<script async src="{{{uiRootPath}}}/js/vendor/highlight.js"></script>
<script src="{{{siteRootPath}}}/pagefind/pagefind-component-ui.js" type="module"></script>
The header above is a trimmed copy of the Default UI header: a real UI keeps the rest of its navbar. The partial
names replace the bundle partials of the same name, which is the override rule explained in
Supplemental UI. siteRootPath is the relative path from the
page to the site root, so the scripts load from pagefind/ at the root whatever the page depth. Then build and index:
npx antora antora-playbook.yml
npx pagefind --site build/site
Source: Pagefind documentation
Pagefind reported this for the two-version Pocket Units sample (12 pages), and the component search box returned
the Converting Lengths pages for the query convert in a browser served with a plain static server:
Did not find a data-pagefind-body element on the site.
↳ Indexing all <body> elements on the site.
...
Indexed 1 language
Indexed 12 pages
Source: Pagefind documentation
Three consequences of this setup, all seen in the sample:
-
With no
data-pagefind-bodyelement anywhere, Pagefind indexes the whole body, including the navigation and the header. To index only the article, add the attribute to an element of the UI (for example by overriding thearticlepartial); the Pagefind documentation describes the attribute and the other indexing options. -
Every component version is indexed, so the same page of versions 1.0 and 2.0 appears twice in the results. The Lunr extension has
index_latest_onlyfor this; Pagefind has no Antora awareness, so restrict what it indexes or use its filters, which the Pagefind documentation covers. -
Pagefind 1.5.0 introduced the Component UI shown here. The older script
pagefind-ui.jswithnew PagefindUI(...)still works (it was run and returned results as well), and the CLI prints a note recommending the Component UI for new integrations.
In CI, run npx pagefind --site build/site between the Antora build and the deployment step (see
Publishing a UI Bundle for the bundle side of the
pipeline).
Algolia DocSearch
DocSearch queries a hosted Algolia index that a crawler builds from the published site, so nothing is produced at
build time. The site has to be accepted by the DocSearch program or crawled with your own Algolia crawler. The current
major version is 5 (@docsearch/js 5.1.2 on npm on the check date); the older 4.x line is documented as legacy.
The Algolia application ID, search-only API key and index name are not secrets in the browser, but keep them out of the
repository text anyway and pass them at build time. A playbook can carry arbitrary named values in site.keys, and a
key written in snake case is available to the templates in camel case (algolia_app_id becomes site.keys.algoliaAppId,
the same way google_analytics becomes googleAnalytics in the Default UI):
site:
title: Pocket Units Docs
url: https://docs.example.org
keys:
algolia_app_id: <YOUR_ALGOLIA_APP_ID>
algolia_search_key: <YOUR_ALGOLIA_SEARCH_KEY>
algolia_index_name: <YOUR_INDEX_NAME>
Source: Antora docs: Site keys
The same keys can be given on the command line with --key name=value, which suits a CI secret.
The supplemental partials mirror the Pagefind ones: a stylesheet in head-meta, a container in header-content
(<div id="docsearch"></div>, without a search-input input, because DocSearch renders its own button and input), and
the script in footer-scripts:
<script id="site-script" src="{{{uiRootPath}}}/js/site.js" data-ui-root-path="{{{uiRootPath}}}"></script>
<script async src="{{{uiRootPath}}}/js/vendor/highlight.js"></script>
{{#with site.keys}}
<script src="https://cdn.jsdelivr.net/npm/@docsearch/js@5.1.2/dist/umd/docsearch.js"></script>
<script>
window.docsearch({
container: '#docsearch',
appId: '{{algoliaAppId}}',
apiKey: '{{algoliaSearchKey}}',
indices: ['{{algoliaIndexName}}'],
})
</script>
{{/with}}
Source: DocSearch: JS package
The stylesheet for head-meta is https://cdn.jsdelivr.net/npm/@docsearch/css@5.1.2/dist/style.css. The CDN form
loads the keyword-search bundle umd/docsearch.js; the bundle umd/index.js also includes Ask AI, which needs an
assistant created in Algolia Agent Studio and an askAi option, and the DocSearch documentation warns against loading
both bundles on one page. Both CDN files returned HTTP 200 on the check date. The templates were rendered by an Antora
build, and the rendered footer contained the placeholder values in the right places, but the widget was not run against
a live Algolia index because that needs an Algolia account and a crawled site. The component displays a
"Search by Algolia" attribution; keep it visible, as the program requires.
Analytics with consent
The Default UI loads Google Analytics unconditionally when the playbook defines site.keys.google_analytics, and that
key can come from the environment variable GOOGLE_ANALYTICS_KEY. That is simple, but it sets cookies before a visitor
has chosen. To load analytics only after consent, override two partials and add one:
-
head-scriptsis replaced by a file with only a comment, so the built-in loader no longer runs. This matters: leaving it in place was observed to load the script on every page even when a consent partial was added. -
A new partial,
analytics-consent, holds the banner and the loader. -
footer-scriptsis replaced by a copy that also includes the new partial.
{{!-- Analytics is loaded after consent by the analytics-consent partial --}}
{{#with site.keys.googleAnalytics}}
<div id="consent-banner" class="consent-banner" hidden>
<p>This site would like to use analytics cookies to count visits.</p>
<button type="button" id="consent-accept">Accept</button>
<button type="button" id="consent-decline">Decline</button>
</div>
<script>
(function () {
var measurementId = '{{this}}'
var banner = document.getElementById('consent-banner')
function loadAnalytics () {
var s = document.createElement('script')
s.async = true
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId)
document.head.appendChild(s)
window.dataLayer = window.dataLayer || []
window.gtag = function () { window.dataLayer.push(arguments) }
window.gtag('js', new Date())
window.gtag('config', measurementId, { anonymize_ip: true })
}
function choose (value) {
try { localStorage.setItem('docs-consent', value) } catch (e) {}
banner.hidden = true
if (value === 'granted') loadAnalytics()
}
var saved = null
try { saved = localStorage.getItem('docs-consent') } catch (e) {}
if (saved === 'granted') loadAnalytics()
else if (saved === null) banner.hidden = false
document.getElementById('consent-accept').addEventListener('click', function () { choose('granted') })
document.getElementById('consent-decline').addEventListener('click', function () { choose('denied') })
})()
</script>
{{/with}}
<script id="site-script" src="{{{uiRootPath}}}/js/site.js" data-ui-root-path="{{{uiRootPath}}}"></script>
<script async src="{{{uiRootPath}}}/js/vendor/highlight.js"></script>
{{#if env.SITE_SEARCH_PROVIDER}}
{{> search-scripts}}
{{/if}}
{{> analytics-consent}}
Source: Antora docs: Site keys
The playbook then needs only site.keys.google_analytics: <YOUR_MEASUREMENT_ID> (a placeholder here, never a real ID
in a repository you share). This was built and exercised in a browser against the Pocket Units sample, with a
placeholder ID: before a choice there was no request for the Google script and the banner was visible; pressing
Accept stored the choice and appended the script tag; on a later load the stored choice loaded it without a banner. The
request itself was not allowed to reach Google, and the banner text, storage key and choice of Google Analytics are
examples: a real site needs consent wording and a policy that match its legal obligations, which this page does not
cover. Any other provider follows the same pattern, since the partial is plain HTML and JavaScript, and a consent
platform that supplies its own script can replace the banner.
Pitfalls and version notes
-
The search field in the Default UI exists only when
env.SITE_SEARCH_PROVIDERis set. Pagefind and DocSearch need a header partial without that condition, or an environment value of your own. -
A UI that has no
search-inputbut includessearch-scripts(for example by forking the Default UI header away) loses Lunr search with no build error; check the browser console. -
data-facet-filtertakescomponent:<name>, and the checkbox must be inside#search-field. -
Do not put
lunr.jsin the UI; use--log-level=infoto see the extension skip it. -
Supplemental files replace bundle files by path, so a copy of
footer-scripts.hbsmust keep the lines you still need (site.jsandhighlight.js) and does not follow later changes of the bundle. Compare it with the bundle when you upgrade the UI. -
The Lunr extension is a pre-release (1.0.0-alpha.13), and DocSearch moved from version 4 to version 5 within the period this section was written; pin versions in CDN URLs and
package.json.