How the site search box works — and how to copy it to another Jekyll site
This document describes the client-side search implementation used in this
repository (a mmistakes/so-simple-theme site built by GitHub Pages) and gives
a step-by-step recipe for dropping the same search box into a different Jekyll
theme hosted on GitHub Pages.
Everything here is pure Liquid + browser JavaScript. No custom Jekyll
plugin is involved, so it works on GitHub Pages, which only allows the
github-pages plugin whitelist.
1. What it does
- A small search
<input>sits in the masthead on every page. - Typing submits a normal
GETrequest to/search/?q=<term>(works with JS disabled). - The
/search/page has a second, larger search box plus an empty#resultsdiv. - On the search page, a bundled copy of lunr.js builds an
in-memory index from a generated JavaScript file and renders results live
as you type (debounced 120 ms), updating the address bar with
?q=. - Results show title, a 20-word excerpt, and a link.
The search index is generated at build time by Jekyll from every document in
site.collections (in this repo: _posts), so the browser has no backend to
talk to.
2. Architecture at a glance
Build time (Jekyll / GitHub Actions)
assets/js/search-data.js -> emits var store = [ {title, excerpt, categories, tags, url}, ... ]
| (a Jekyll page: has front matter, Liquid runs, output is JavaScript)
v
_site/assets/js/search-data.js
Request time (browser)
any page --[masthead form, GET /search/?q=term]--> /search/
|
_layouts/search.html renders two boxes + <div id="results">
_includes/scripts.html (only when page.layout == "search")
-> _includes/lunr-search-scripts.html
-> loads lunr.min.js, search-data.js
-> builds lunr index over `store`
-> binds input#search, renders matches into #results
3. Dependencies
| Dependency | Version used here | Where it comes from |
|---|---|---|
| Jekyll | 3.10.0 (github-pages v232) |
GitHub Actions |
| lunr.js | 2.1.5 (must be 2.x) | vendored in assets/js/lunr/lunr.min.js |
| jQuery | 3.3.1 | _includes/scripts.html (CDN) |
| Font Awesome | 5.0.12 | _includes/scripts.html (CDN) — only for the magnifier icon |
lunr 2.x is required: the query code uses
lunr.tokenizer.separator,lunr.Query.wildcard.TRAILING, andeditDistance, which do not exist in lunr 1.x. The 2.3.x release is also compatible if you vendor a newer copy.
4. Files involved
| File | Role |
|---|---|
search.md |
The /search/ page stub (front matter only) |
_layouts/search.html |
Page layout: large search form + #results container |
_includes/search-form.html |
The compact masthead search box |
_includes/masthead.html |
Renders the compact box (must include the file above) |
_includes/scripts.html |
Loads the search JS only on the search page |
_includes/lunr-search-scripts.html |
Builds the lunr index and all client logic |
assets/js/search-data.js |
Build-time index generator (Liquid → JS) |
assets/js/lunr/*.min.js |
vendored lunr runtime (+ optional language packs) |
assets/css/custom.css |
Styling for both forms and results |
_data/text.yml |
Placeholder and result labels, per locale |
_config.yml |
search_full_content, locale |
TL;DR for porting: add rows 1, 3, 6 and 7 as-is, adapt rows 2, 4 and 5 to your
theme’s layout/header/script includes, vendor lunr (row 8), merge the CSS block,
copy the three _data/text.yml keys, and add the two one-line include hooks.
5. Step-by-step implementation
Step 1 — Vendor lunr.js
Create assets/js/lunr/ and copy at minimum:
assets/js/lunr/lunr.min.js
Copy lunr.stemmer.support.min.js plus the relevant lunr.<lang>.min.js
(e.g. lunr.de.min.js, lunr.fr.min.js) only if the site is not English.
This repo vendors the full set (19 files) from lunr 2.1.5; it costs nothing
unless referenced, since Jekyll just copies them into _site.
Step 2 — Generate the index at build time
assets/js/search-data.js (note the front matter — it is what makes Jekyll run
Liquid over a .js file):
---
layout: null
sitemap: false
---
var store = [
{%- for c in site.collections -%}
{%- if forloop.last -%}
{%- assign l = true -%}
{%- endif -%}
{%- assign docs = c.docs | where_exp: 'doc', 'doc.search != false' -%}
{%- for doc in docs -%}
{
"title": {{ doc.title | jsonify }},
"excerpt":
{%- if site.search_full_content == true -%}
{{ doc.content |
replace:"</p>", " " |
replace:"</h1>", " " |
replace:"</h2>", " " |
replace:"</h3>", " " |
replace:"</h4>", " " |
replace:"</h5>", " " |
replace:"</h6>", " "|
strip_html | strip_newlines | jsonify }},
{%- else -%}
{{ doc.content |
replace:"</p>", " " |
replace:"</h1>", " " |
replace:"</h2>", " " |
replace:"</h3>", " " |
replace:"</h4>", " " |
replace:"</h5>", " " |
replace:"</h6>", " "|
strip_html | strip_newlines | truncatewords: 50 | jsonify }},
{%- endif -%}
"categories": {{ doc.categories | jsonify }},
"tags": {{ doc.tags | jsonify }},
"url": {{ doc.url | absolute_url | jsonify }}
} {%- unless forloop.last and l -%}, {%- endunless -%}
{%- endfor -%}
{%- endfor -%}
]
Key points:
layout: nullandsitemap: falsekeep the output as raw JS and out of the sitemap.- Only collections are indexed (
site.collections). Standalone pages such asabout.mdare not included — see the gotchas in §8 if you want pages too. doc.search != falselets a document opt out withsearch: falsein its front matter.search_full_content: falsetruncates the body to 50 words; set it totruein_config.ymlto index the whole post (heavier page, better recall).urlis absolute, so results can be linked from anywhere.
Step 3 — The /search/ page
search.md (front matter only; the layout renders everything):
---
title: Search
layout: search
permalink: /search/
sitemap: false
---
Step 4 — The search page layout
_layouts/search.html:
---
layout: page
---
{{ content }}
<form class="search-form search-form--page" action="{{ '/search/' | relative_url }}" method="get" role="search">
<label class="screen-reader-text" for="search">Search</label>
<input type="search" id="search" name="q" class="search-input" placeholder="{{ site.data.text[site.locale].search_placeholder_text | default: 'Enter your search term...' }}" autofocus autocomplete="off">
<button type="submit" class="search-submit" aria-label="Search"><i class="fas fa-search" aria-hidden="true"></i><span>Search</span></button>
</form>
<div id="results" aria-live="polite"></div>
The IDs and names here are load-bearing:
| Attribute | Why it matters |
|---|---|
id="search" |
The JS binds to input#search |
name="q" |
The query parameter the form submits and the JS reads |
#results |
Target container for rendered matches |
aria-live="polite" |
Screen readers announce result updates |
layout: page |
Inherits the theme chrome; page.layout becomes search |
Step 5 — The compact masthead box
_includes/search-form.html:
{%- unless page.layout == "search" -%}
{%- assign search_placeholder = site.data.text[site.locale].search_placeholder_text | default: "Search the site..." -%}
<form class="site-search" action="{{ '/search/' | relative_url }}" method="get" role="search">
<label class="screen-reader-text" for="site-search-input">Search</label>
<input type="search" id="site-search-input" name="q" placeholder="{{ search_placeholder }}" autocomplete="off">
<button type="submit" aria-label="Search"><i class="fas fa-search" aria-hidden="true"></i><span>Search</span></button>
</form>
{%- endunless -%}
This box intentionally has no JavaScript. It is a plain GET form: on any
page it navigates to /search/?q=<term>, and the search page picks the term up
from the URL on load. It is hidden on the search page itself to avoid two
competing inputs.
Wire it into the masthead with one line inside _includes/masthead.html:
{% include search-form.html %}
Step 6 — Load the search JavaScript only on the search page
_includes/scripts.html ends with:
{%- if page.layout == "search" -%}
{% include lunr-search-scripts.html %}
{%- endif -%}
Because scripts.html is included by the default layout, this runs on every
page but only emits code when page.layout == "search". Visitors reading a post
never download lunr.js or the index.
This file also guarantees the two runtime dependencies: jQuery (line 11) and Font Awesome (line 14). If another theme does not load them, add them yourself.
Step 7 — The search engine and UI logic
_includes/lunr-search-scripts.html — the complete file:
{%- assign lang = site.locale | slice: 0,2 | default: "en" -%}
{%- case lang -%}
{%- when "da" -%}
{%- assign lang = "da" -%}
{%- when "de" -%}
{%- assign lang = "de" -%}
{%- when "du" -%}
{%- assign lang = "du" -%}
{%- when "es" -%}
{%- assign lang = "es" -%}
{%- when "fi" -%}
{%- assign lang = "fi" -%}
{%- when "fr" -%}
{%- assign lang = "fr" -%}
{%- when "hu" -%}
{%- assign lang = "hu" -%}
{%- when "it" -%}
{%- assign lang = "it" -%}
{%- when "ja" -%}
{%- assign lang = "ja" -%}
{%- when "jp" -%}
{%- assign lang = "jp" -%}
{%- when "no" -%}
{%- assign lang = "no" -%}
{%- when "pt" -%}
{%- assign lang = "pt" -%}
{%- when "ro" -%}
{%- assign lang = "ro" -%}
{%- when "ru" -%}
{%- assign lang = "ru" -%}
{%- when "sv" -%}
{%- assign lang = "sv" -%}
{%- when "tr" -%}
{%- assign lang = "tr" -%}
{%- else -%}
{%- assign lang = "en" -%}
{%- endcase -%}
<script src="{{ '/assets/js/lunr/lunr.min.js' | relative_url }}"></script>
<script src="{{ '/assets/js/search-data.js' | relative_url }}"></script>
{%- unless lang == "en" -%}
<script src="{{ '/assets/js/lunr/lunr.stemmer.support.min.js' | relative_url }}"></script>
<script src="{{ '/assets/js/lunr/lunr.' | append: lang | append: '.min.js' | relative_url }}"></script>
{%- endunless -%}
<script>
(function () {
var idx = lunr(function () {
{% unless lang == "en" %}
// use the language
this.use(lunr.{{ lang }});
{% endunless %}
// the normal lunr index initialization
this.field('title');
this.field('excerpt');
this.field('categories');
this.field('tags');
this.ref('id');
this.pipeline.remove(lunr.trimmer);
// add documents to index
for (var item in store) {
this.add({
title: store[item].title,
excerpt: store[item].excerpt,
categories: store[item].categories,
tags: store[item].tags,
id: item
});
}
});
var resultsText = {{ site.data.text[site.locale].results_found | default: "Result(s) found" | jsonify }};
var noResultsText = {{ site.data.text[site.locale].no_results | default: "No results found." | jsonify }};
function escapeHtml(value) {
return $('<div>').text(value == null ? '' : String(value)).html();
}
function escapeAttr(value) {
return escapeHtml(value).replace(/"/g, '"').replace(/'/g, ''');
}
function renderResults(value) {
var query = $.trim((value || '').toLowerCase());
var resultDiv = $('#results');
resultDiv.empty();
if (!query) {
return;
}
var result = idx.query(function (q) {
query.split(lunr.tokenizer.separator).forEach(function (term) {
if (!term) {
return;
}
q.term(term, { boost: 100 });
if (query.lastIndexOf(' ') != query.length - 1) {
q.term(term, { usePipeline: false, wildcard: lunr.Query.wildcard.TRAILING, boost: 10 });
}
q.term(term, { usePipeline: false, editDistance: 1, boost: 1 });
});
});
resultDiv.append('<p class="results__found">' + result.length + ' ' + escapeHtml(resultsText) + '</p>');
if (!result.length) {
resultDiv.append('<p class="results__empty">' + escapeHtml(noResultsText) + '</p>');
return;
}
result.forEach(function (match) {
var item = store[match.ref];
if (!item) {
return;
}
var excerpt = (item.excerpt || '').split(/\s+/).slice(0, 20).join(' ');
resultDiv.append(
'<article class="entry">' +
'<h3 class="entry-title">' +
'<a href="' + escapeAttr(item.url) + '">' + escapeHtml(item.title) + '</a>' +
'</h3>' +
'<div class="entry-excerpt">' +
'<p>' + escapeHtml(excerpt) + (excerpt ? '…' : '') + '</p>' +
'</div>' +
'</article>'
);
});
}
function updateUrl(value) {
if (!window.history || !window.history.replaceState) {
return;
}
var url = new URL(window.location.href);
if ($.trim(value)) {
url.searchParams.set('q', $.trim(value));
} else {
url.searchParams.delete('q');
}
window.history.replaceState(null, '', url.pathname + url.search);
}
$(document).ready(function () {
var $input = $('input#search');
if (!$input.length) {
return;
}
var params = new URLSearchParams(window.location.search);
var initial = params.get('q') || params.get('query') || '';
if (initial) {
$input.val(initial);
renderResults(initial);
}
var timer;
$input.on('input keyup search', function () {
var value = $(this).val();
clearTimeout(timer);
timer = setTimeout(function () {
renderResults(value);
updateUrl(value);
}, 120);
});
$input.closest('form').on('submit', function (event) {
event.preventDefault();
clearTimeout(timer);
renderResults($input.val());
updateUrl($input.val());
});
});
})();
</script>
How the query works
For every whitespace-separated term lunr adds three clauses:
- exact match —
boost: 100(dominant ranking signal) - prefix wildcard —
usePipeline: false, wildcard: TRAILING, boost: 10, applied only when the query does not end in a space, so results appear while the user is still typing - fuzzy match —
editDistance: 1, boost: 1(tolerates one typo)
this.pipeline.remove(lunr.trimmer) keeps tokens containing punctuation (useful
for posts like CVE-2026-124194) from being mangled by the default trimmer.
Indexed fields are title, excerpt, categories, tags, with the array
index as ref. Excerpts are re-truncated to 20 words at render time purely for
display.
Output safety
Titles and excerpts are passed through escapeHtml() (jQuery’s .text()→.html()
trick) and URLs through escapeAttr() before string-concatenation into HTML.
Do not remove these — post titles come from front matter and can contain any
character.
URL state
- On load: reads
?q=(falls back to legacy?query=) and renders immediately, so a masthead submission works without JS-side navigation. - On typing:
history.replaceStaterewrites?q=without adding history entries. - On submit:
preventDefault()and render inline.
Step 8 — Styles
Add the following block to assets/css/custom.css (this repo’s theme loads it
via _layouts/default.html: <link rel="stylesheet" href="{{ '/assets/css/custom.css' | relative_url }}">):
/* ==========================================================================
Site search
========================================================================== */
.site-search,
.search-form--page {
display: flex;
align-items: stretch;
justify-content: center;
gap: 0.5rem;
max-width: 36rem;
margin: 1.25rem auto 0;
text-align: left;
}
.search-form--page {
margin-top: 0;
margin-bottom: 2rem;
}
.site-search {
padding-right: 1rem;
padding-left: 1rem;
}
.site-search input[type="search"],
.search-form--page input[type="search"] {
flex: 1 1 auto;
width: auto;
min-width: 0;
margin: 0;
padding: 0.65em 1.1em;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
color: #1a1a1a;
font-family: inherit;
font-size: 0.95rem;
line-height: 1.4;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}
.site-search input[type="search"]:focus,
.search-form--page input[type="search"]:focus {
outline: 3px solid rgba(255, 255, 255, 0.7);
outline-offset: 2px;
}
.site-search button,
.search-form--page .search-submit {
flex: 0 0 auto;
padding: 0.65em 1.2em;
border: 0;
border-radius: 999px;
background: #000;
color: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}
.site-search button:hover,
.site-search button:focus,
.search-form--page .search-submit:hover,
.search-form--page .search-submit:focus {
background: #34374c;
color: #fff;
}
.site-search button i,
.search-form--page .search-submit i {
margin-right: 0.45em;
}
#results {
margin-top: 1rem;
}
.results__found {
font-weight: 700;
}
.results__empty {
opacity: 0.85;
}
@media (max-width: 30em) {
.site-search button span,
.search-form--page .search-submit span {
display: none;
}
.site-search button i,
.search-form--page .search-submit i {
margin-right: 0;
}
}
Note the mobile rule hides the button’s text span and relies on the Font
Awesome magnifier. If the target theme has no Font Awesome, drop that
@media block so the button keeps its “Search” label.
Step 9 — Labels (_data/text.yml)
Three keys under each locale (English shown; German/Spanish/French/Italian/ Turkish already exist in this repo’s file):
en: &DEFAULT_EN
results_found: "Result(s) found"
no_results: "No results found. Try a different search term."
search_placeholder_text: "Enter your search term..."
en-US:
<<: *DEFAULT_EN
The templates look these up with site.data.text[site.locale].<key> and every
lookup has a | default: fallback, so a missing locale still renders English.
If your site has no _data/text.yml, either create it with this shape or hard-code
the strings in the three templates.
Step 10 — Config
locale: en-US # drives placeholder + lunr language pack
search_full_content: false # true = index full post bodies (larger search-data.js)
There is intentionally no plugin entry: lunr is client-side and
search-data.js is generated with stock Liquid.
6. Runtime flow, end to end
- Visitor clicks a masthead search box on any page and presses Enter.
- Browser does
GET /search/?q=term. - GitHub Pages serves the static
/search/page built fromsearch.md. _layouts/search.htmlrenders the large form and#results.scripts.htmlseespage.layout == "search"and includeslunr-search-scripts.html, which loadslunr.min.jsthensearch-data.js.- The inline script builds the index from
store, bindsinput#search, reads?q=term, and renders matches. - Further keystrokes re-query locally — no network calls, no backend.
history.replaceStatekeeps?q=in sync so the result page is shareable.
7. Porting checklist for another GitHub Pages theme
- Copy files
_layouts/search.html_includes/search-form.html_includes/lunr-search-scripts.htmlassets/js/search-data.jsassets/js/lunr/lunr.min.js
- Add the page
search.mdwithlayout: search,permalink: /search/. - Hook the JS — add the
page.layout == "search"guard from Step 6 to whichever include your target theme renders before</body>(often_includes/scripts.html,_includes/footer.html, or the layout directly). - Hook the form — add
{% include search-form.html %}in the theme’s header or navigation include. If there is no suitable place, put it in the layout and rely on the/search/page’s own large box. - Ensure jQuery and Font Awesome are loaded on the search page. If the
theme does not use jQuery, either add it (one
<script>tag) or port the script to vanilla JS — the only jQuery APIs used are$(),.val(),.on(),.closest(),.empty(),.append(),.trim(),.text(), and.html(). - Merge the CSS from Step 8, adapting the color/shadow values to the target theme.
- Add
_data/text.ymlkeys (Step 9) or hard-code the labels. - Set
localein_config.yml; add the matchinglunr.<lang>.min.jsand include it via the existingunless lang == "en"branch for non-English sites. - Build and test locally, then commit every new file (see §8).
Minimal diff for a theme that already has a scripts.html and a header include:
<!-- _includes/scripts.html, before </body> -->
+ {%- if page.layout == "search" -%}
+ {% include lunr-search-scripts.html %}
+ {%- endif -%}
<!-- _includes/masthead.html (or header.html), inside the header -->
+ {% include search-form.html %}
8. Gotchas (learned from this repo)
8.1 Untracked files break the GitHub Actions build
GitHub Actions builds only the committed tree. _includes/masthead.html
referencing {% include search-form.html %} while that file exists only in the
working tree produces:
Liquid Exception: Could not locate the included file 'search-form.html' in any of
["/github/workspace/_includes", "/tmp/jekyll-remote-theme-.../_includes"] ... in /_layouts/default.html
Before pushing, verify every new file is tracked:
git status --porcelain # look for '??' in _includes/, _layouts/, assets/
git ls-files _includes/search-form.html assets/js/search-data.js
A quick audit for any include referenced from tracked templates but absent from git:
git ls-files '_includes/*' '_layouts/*' '*.md' \
| xargs grep -hoE '\{%-? *include +=? *[^ %]+' \
| sed -E 's/.*include *//; s/=//' | tr -d '"' | sort -u \
| while read f; do [ -e "_includes/$f" ] || echo "MISSING: $f"; done
8.2 GitHub Pages “safe mode”
- Files in
_includes/must not be symlinks (the error text says so explicitly). If you keep shared includes in another directory and symlink them, the build fails on Pages while working locally. - No custom
_plugins— this is why the index generator is a Liquid-templated.jsasset rather than a Ruby generator.
8.3 assets/js/search-data.js needs front matter
Without the --- block Jekyll copies the file verbatim and Liquid never runs,
so the browser receives literal {%- for c in site.collections -%} and the
console shows a syntax error. Keep at least:
---
layout: null
---
8.4 Only collections are indexed
site.collections contains posts plus collections declared in _config.yml.
Standalone pages (about.md, index.md, project pages) are not searchable.
To include them, extend the generator with, for example:
{%- for p in site.pages -%}
{%- unless p.url contains '.js' or p.url contains '.css' or p.search == false -%}
{ "title": {{ p.title | jsonify }},
"excerpt": {{ p.content | strip_html | strip_newlines | truncatewords: 50 | jsonify }},
"categories": {{ p.categories | jsonify }},
"tags": {{ p.tags | jsonify }},
"url": {{ p.url | absolute_url | jsonify }} },
{%- endunless -%}
{%- endfor -%}
Watch the comma placement — the template builds JSON by hand, so a trailing
comma or a missing one makes store invalid JavaScript and silently kills
search.
8.5 ID collisions / duplicate inputs
Only the input with id="search" gets live results. The masthead input uses
id="site-search-input" on purpose: two elements sharing id="search" would
bind the handler to the first one found and leave the other inert.
8.6 Search page must not also show the masthead box
search-form.html is wrapped in {%- unless page.layout == "search" -%}. If you
remove that guard, the search page shows two boxes, and the smaller one’s input
is never read.
8.7 jQuery must exist before the search script runs
lunr-search-scripts.html assumes $. In this repo the theme’s scripts.html
loads jQuery at line 11 and the search include at line 52, so ordering is
correct. If your theme loads scripts in <head> with defer, or omits jQuery,
the $(document).ready(...) block throws $ is not defined and nothing is
searchable. Check the browser console first when results never appear.
8.8 Front matter can opt a post out
search: false in a post’s front matter removes it from store (the
where_exp filter). Useful for drafts, redirects, or very large posts.
8.9 Performance
search_full_content: falsekeepssearch-data.jssmall (50 words per doc). With thousands of posts, enabling full content can push the file into the megabytes.- The index is rebuilt in the browser on every visit to
/search/; it is not cached across page loads. For a few hundred posts that is milliseconds. - lunr.js (~30 KB minified) is loaded only on the search page thanks to the
page.layout == "search"guard.
9. Optional improvements
- Limit rendered results (e.g. top 20) to avoid long pages on short queries.
- Highlight matches: wrap query terms in
<mark>inside the excerpt (remember to escape first, then insert the tags). - Keyboard shortcut: focus
#searchon/orCtrl/Cmd+K. - Index headings: parse
doc.contentfor<h2>/<h3>at build time and add aheadingsfield for better ranking. - Server-side alternative:
jekyll-lunr-js-searchand similar plugins do the same job but add configuration; the hand-rolled version above has no plugin dependency and survives theme swaps.