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

header-content

Renders the navbar. When env.SITE_SEARCH_PROVIDER is set it renders the search field containing <input id="search-input" type="text">.

footer-scripts

Loads site.js and highlight.js, then includes the search-scripts partial when env.SITE_SEARCH_PROVIDER is set.

head-scripts

Loads Google Analytics when the playbook defines the key site.keys.google_analytics.

head-meta

Empty on purpose: it holds a comment inviting additional meta tags, so it is a good place for a stylesheet link.

The figure shows the three search options side by side. The only difference is who produces the index and who loads the script.

flowchart LR A["Antora build"] --> B["Site HTML"] A -->|"Lunr extension: uiLoaded and beforePublish"| C["search-index.js and search-scripts partial"] B -->|"npx pagefind --site"| D["pagefind folder in the site"] E["Algolia crawler"] -->|"indexes the published site"| F["Hosted index"] C --> G["Browser: input#search-input"] D --> H["Browser: Pagefind widget"] F --> I["Browser: DocSearch widget"]

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 uiLoaded event it sets env.SITE_SEARCH_PROVIDER to lunr. The templates test that value, so the search field is rendered only when the extension is active. The extension removes the value again on beforePublish.

  • It adds js/vendor/lunr.js, js/search-ui.js and css/search.css to the UI catalog, unless a file with the same path already exists. For the search-scripts partial it does the same: if the UI already has partials/search-scripts.hbs the UI copy is kept, otherwise the extension adds its own.

  • On beforePublish it writes search-index.js at 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 id search-input. The script finds it with document.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-field and the attribute data-facet-filter. The value is component: followed by the component name, so that ticking it restricts results to the current component.

  • No copy of lunr.js in 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}}

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.

<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

The three supplemental partials, in a directory used as ui.supplemental_files: ./supplemental-ui:

supplemental-ui/partials/head-meta.hbs
<link rel="stylesheet" href="{{{siteRootPath}}}/pagefind/pagefind-component-ui.css">
supplemental-ui/partials/header-content.hbs
<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>
supplemental-ui/partials/footer-scripts.hbs
<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

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

Three consequences of this setup, all seen in the sample:

  • With no data-pagefind-body element 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 the article partial); 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_only for 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.js with new 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):

antora-playbook.yml (excerpt)
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>

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:

supplemental-ui/partials/footer-scripts.hbs
<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}}

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.

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-scripts is 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-scripts is replaced by a copy that also includes the new partial.

supplemental-ui/partials/head-scripts.hbs
{{!-- Analytics is loaded after consent by the analytics-consent partial --}}
supplemental-ui/partials/analytics-consent.hbs
{{#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}}
supplemental-ui/partials/footer-scripts.hbs
<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}}

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_PROVIDER is set. Pagefind and DocSearch need a header partial without that condition, or an environment value of your own.

  • A UI that has no search-input but includes search-scripts (for example by forking the Default UI header away) loses Lunr search with no build error; check the browser console.

  • data-facet-filter takes component:<name>, and the checkbox must be inside #search-field.

  • Do not put lunr.js in the UI; use --log-level=info to see the extension skip it.

  • Supplemental files replace bundle files by path, so a copy of footer-scripts.hbs must keep the lines you still need (site.js and highlight.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.