Skip to content

Search Implementation -- FlexSearch, Pagefind & Lunr

DodaTech Updated 2026-06-22 9 min read

In this tutorial, you'll learn about Search Implementation. We cover key concepts, practical examples, and best practices.

Adding search to a static site requires a fundamentally different approach than server-side search -- instead of querying a database at request time, you prebuild a search index and perform queries entirely on the client side or via a serverless function at the edge.

What You'll Learn

Why It Matters

Static sites have no database to query at runtime, so traditional server-side search (SQL LIKE queries, Elasticsearch, Algolia) either does not work or requires external services. Client-side search solves this by generating a search index during the build and performing lookups in the browser or at the edge. This approach is free (no third-party service), fast (no network requests), and privacy-friendly (user queries never leave their device). At DodaTech, we use FlexSearch for our tutorial search, indexing over 2,900 pages into a 400KB JSON file that loads instantly and searches in under 10ms.

Real-World Use

A documentation site with 5,000 pages uses Pagefind to provide instant full-text search without any server infrastructure. A personal blog uses Lunr.js to index 200 posts into a client-side search index. A SaaS marketing site uses FlexSearch with a serverless function for fuzzy search across product documentation, blog posts, and changelogs.

Search Architecture Comparison

flowchart LR
  subgraph Build[Build Time]
    A[Content Pages] --> B[Search Index Generator]
    B --> C[JSON Index File]
  end
  subgraph Client[Client Side]
    D[Page Load] --> E[Download Index]
    E --> F[Initialize Search Engine]
    F --> G[User Types Query]
    G --> H[Local Search]
    H --> I[Display Results]
  end
  subgraph Edge[Edge Option]
    J[Worker Endpoint] --> K[KV Store]
    K --> L[Return Results]
  end
  style B fill:#f90,color:#fff

Search Solution Comparison

Feature FlexSearch Pagefind Lunr Cloudflare Workers + KV
Index type In-memory document Static files In-memory document KV key-value
Index size Small (~0.5KB/doc) Medium (~1KB/doc) Large (~2KB/doc) Depends on KV
Search speed <10ms (100K docs) <50ms (10K docs) <100ms (10K docs) <200ms (edge)
Fuzzy search Yes (built-in) Yes (built-in) No (plugin needed) Manual implementation
Zero config No Yes No No
Bundle size 8KB gzipped 20KB gzipped 25KB gzipped N/A (edge)
Language support Multi-language Multi-language English only Custom
Privacy Fully client-side Fully client-side Fully client-side Edge-processed

FlexSearch is a lightweight, high-performance search library that supports fuzzy matching, multi-field search, and custom tokenizers.

Build-Time Index Generation (Hugo)

{{/* layouts/_default/search-index.json.json */}}
{{ $pages := where .Site.RegularPages "Kind" "page" -}}
{{ $index := slice -}}
{{ range $pages -}}
  {{ $index = $index | append (dict
    "id" .File.UniqueID
    "title" .Title
    "url" .RelPermalink
    "description" .Description
    "content" (plainify .Content | truncate 500)
    "tags" .Params.tags
    "section" .Section
  ) -}}
{{ end -}}
{{ $index | jsonify -}}

Expected behavior: During hugo build, this template generates /search-index.json containing all regular pages with their title, URL, description, truncated content, tags, and section. The file is typically 200-500KB for a medium-sized site.

Client-Side Search Implementation

// assets/js/flexsearch-init.js -- FlexSearch initialization
import FlexSearch from 'flexsearch';

let searchInstance = null;
let searchStore = [];

export async function initSearch() {
  if (searchInstance) return;

  searchInstance = new FlexSearch.Document({
    document: {
      id: 'id',
      index: [
        {
          field: 'title',
          tokenize: 'forward',
          resolution: 9,
        },
        {
          field: 'content',
          tokenize: 'forward',
          resolution: 5,
        },
        {
          field: 'tags',
          tokenize: 'forward',
          resolution: 3,
        },
      ],
      store: ['title', 'url', 'description', 'section'],
    },
    tokenize: 'forward',
    cache: 100,
  });

  const response = await fetch('/search-index.json');
  const pages = await response.json();

  pages.forEach(page => searchInstance.add(page));
  searchStore = pages;
}

export function search(query, limit = 20) {
  if (!searchInstance || query.length < 2) return [];

  const results = searchInstance.search(query, limit, {
    enrich: true,
    suggest: true,
  });

  // Flatten and weight results by field match
  const weighted = new Map();

  results.forEach(field => {
    const weight = field.field === 'title' ? 3 : field.field === 'tags' ? 2 : 1;
    field.result.forEach(item => {
      const existing = weighted.get(item.id) || { score: 0, ...item };
      existing.score += weight;
      weighted.set(item.id, existing);
    });
  });

  return Array.from(weighted.values())
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}

Expected behavior: On page load, initSearch() fetches the index and initializes FlexSearch. Typing a query calls search(), which matches against title, content, and tags with different weights. Results are sorted by relevance and returned in under 10ms.

Pagefind is a search library designed specifically for static sites. It requires no configuration -- just run the CLI and add the UI.

Pagefind Integration

# Install and run Pagefind after the site build
npm install -g pagefind
hugo --gc --minify
pagefind --site public --output-subdir ../static/pagefind
<!-- layouts/partials/search-modal.html -->
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>

<div id="search-modal" class="search-modal hidden">
  <div id="search"></div>
</div>

<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({
      element: '#search',
      showSubResults: true,
      showImages: false,
      resetStyles: false,
      bundlePath: '/pagefind/',
      highlightParam: 'highlight',
    });
  });
</script>

Expected behavior: Pagefind scans the output directory, generates a search index as static files, and provides a drop-in UI component. The search modal opens with Ctrl+K, and results appear as the user types. Pagefind handles stemming, typo tolerance, and ranking automatically.

Customizing Pagefind with Filtering

<script>
  const search = new PagefindUI({
    element: '#search',
    showSubResults: true,
    filters: {
      section: ['static-sites', 'game-development'],
tags: ['hugo', 'javascript'],
    },
    sort: {
      weight: 'desc',
    },
  });
</script>

Expected behavior: Users can filter search results by section or tags using the built-in filter UI. Results can be sorted by a custom weight field defined in the page frontmatter.

Lunr is a browser-based full-text search library inspired by Solr. It is simpler than FlexSearch but larger and slower for large indexes.

// scripts/generate-lunr-index.js -- Build-time index generation
const lunr = require('lunr');
const fs = require('fs');
const path = require('path');

// Load page data (generated by Hugo)
const pages = JSON.parse(
  fs.readFileSync('./public/search-data.json', 'utf-8')
);

const idx = lunr(function () {
  this.ref('id');
  this.field('title', { boost: 10 });
  this.field('content', { boost: 5 });
  this.field('tags', { boost: 3 });

  pages.forEach(page => {
    this.add(page);
  });
});

fs.writeFileSync('./static/lunr-index.json', JSON.stringify(idx));
// assets/js/lunr-search.js -- Client-side Lunr search
let lunrIndex = null;
let lunrStore = {};

async function initLunr() {
  const [indexData, storeData] = await Promise.all([
    fetch('/lunr-index.json').then(r => r.json()),
    fetch('/search-store.json').then(r => r.json()),
  ]);

  lunrIndex = lunr.Index.load(indexData);
  lunrStore = storeData;
}

function searchLunr(query) {
  if (!lunrIndex || query.length < 2) return [];

  try {
    const results = lunrIndex.search(query);
    return results.slice(0, 20).map(result => ({
      ...lunrStore[result.ref],
      score: result.score,
    }));
  } catch (e) {
    console.error('Lunr search error:', e);
    return [];
  }
}

Expected behavior: Lunr generates a serialized index during the build. On the client, the index is loaded and queried. Results include a relevance score and page metadata from the store. Lunr supports wildcard queries, field-specific searches, and boolean operators.

Common Errors

1. Index Size Exceeding Browser Memory

A search index with full page content for 10,000+ pages can exceed 50MB, causing slow downloads and high memory usage. Truncate content to 300-500 characters in the index and fetch full content on result click.

2. Not Debouncing Search Input

Triggering a search on every keystroke without debouncing causes hundreds of searches per second, freezing the UI. Debounce search input by 150-300ms before executing the query.

// Search debounce helper
function debounce(fn, delay = 200) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', debounce((e) => {
  const results = performSearch(e.target.value);
  renderResults(results);
}, 200));

3. Ignoring Search Result Accessibility

Search results must be navigable with keyboard (arrow keys, Enter, Escape) and announced to screen readers. Add role="listbox", aria-selected, and aria-activedescendant attributes to the results container.

4. Missing No-Results State

When a search returns zero results, show a helpful message instead of an empty box. Suggest similar terms or offer to search again with different keywords.

5. Index Not Updating After Content Changes

If the search index is generated at build time but not regenerated on content changes, search results become stale. Ensure the index generation runs as part of the build pipeline, not manually.

Practice Questions

1. What is the main advantage of client-side search over server-side search for static sites?

Client-side search requires no server infrastructure, no third-party API keys, and no ongoing costs. Queries run locally on the user's device, providing instant results and complete privacy.

2. How does FlexSearch handle fuzzy search differently from exact matching?

FlexSearch uses a configurable resolution setting (1-9) to determine how strictly tokens must match. Lower resolution allows more character mismatches, enabling typo-tolerant search. It also supports n-gram tokenization for partial matching.

3. What is the purpose of debouncing search input?

Debouncing prevents the search function from executing on every keystroke. It waits for a pause in typing (typically 150-300ms) before triggering the search, reducing unnecessary computations and preventing UI freezing.

4. How does Pagefind differ from FlexSearch in terms of setup and usage?

Pagefind is zero-configuration -- you run a CLI command after the build and add a UI component to the page. FlexSearch requires manual index generation, initialization, and search logic. Pagefind is easier to set up; FlexSearch offers more control and better performance for large indexes.

5. Challenge: Implement keyboard navigation for search results (arrow keys to move, Enter to select, Escape to close).

Add event listeners for ArrowDown, ArrowUp, Enter, and Escape keys. Track the active result index with a ref, update visual focus with a CSS class, and call scrollIntoView() for off-screen results. Close the search modal on Escape.

Mini Project: Full-Text Search for a Hugo Site

Implement a complete search feature for a Hugo site using FlexSearch:

  1. Create layouts/_default/search-index.json.json to generate the index during build
  2. Install FlexSearch via npm: npm install flexsearch
  3. Create assets/js/search.js with initialization, query, and results rendering
  4. Add a search modal partial with input, results container, and keyboard navigation
  5. Style the search modal with CSS (dark overlay, centered input, scrollable results)
  6. Register the JS bundle in your base template
{{/* layouts/partials/search-modal.html */}}
<div id="search-overlay" class="search-overlay hidden" role="dialog" aria-label="Search">
  <div class="search-modal">
    <input
      type="text"
      id="search-input"
      placeholder="Search tutorials... (Ctrl+K)"
      autocomplete="off"
      role="combobox"
      aria-expanded="false"
      aria-controls="search-results"
    >
    <div id="search-results" role="listbox" class="search-results"></div>
    <div id="search-empty" class="search-empty hidden">
      No results found. Try different keywords.
    </div>
  </div>
</div>

Test the implementation by searching for a term that appears in your content, verifying that:

  • Results appear within 50ms
  • Keyboard navigation works (arrow keys, Enter, Escape)
  • The index file size is reasonable (under 1MB for up to 5,000 pages)
  • Search works offline (service worker caching the index file)

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro