Skip to content

SSG Search — Adding Search Functionality to Static Sites

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about SSG Search. We cover key concepts, practical examples, and best practices to help you master this topic.

Adding search to static sites requires client-side indexing since there's no server to Process queries. Lunr.js, Pagefind, and Algolia offer different approaches.

What You'll Learn

By the end of this tutorial, you'll understand how to add search to a static site using Lunr.js for client-side search, Pagefind for zero-config indexing, and Algolia for hosted search.

Why It Matters

Static sites don't have a backend to handle search queries. Implementing search requires generating a search index at build time and querying it client-side. Good search is critical for documentation and content-heavy sites.

Real-World Use

A documentation site with 500 pages indexes all content at build time using Pagefind. Users search instantly on the client side without any server costs. The index is under 1MB and updates with each build.

SSG Search Architecture

graph TD
    A[Build Process] --> B[Content pages
HTML / Markdown] B --> C[Search indexer] C --> D[Generate
search index] C --> E[Generate
search metadata] D --> F[Index file
JSON / JS] E --> G[Page metadata
titles, excerpts] F --> H[Client-side
search library] G --> H H --> I[User search
interface] I --> J[Instant results
no server needed] style C fill:#4a90d9,color:#fff style H fill:#e67e22,color:#fff style I fill:#27ae60,color:#fff
// scripts/generate-search-index.js — Build-time index generation
const fs = require('fs');
const path = require('path');
const glob = require('glob');

const contentDir = './public';
const outputDir = './public/search';

if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir, { recursive: true });
}

// Collect all HTML files and extract text
const files = glob.sync(`${contentDir}/**/*.html`);
const documents = [];

files.forEach(filePath => {
    const html = fs.readFileSync(filePath, 'utf8');
    const relativePath = path.relative(contentDir, filePath);

    // Extract title from <title> tag
    const titleMatch = html.match(/<title>(.*?)<\/title>/);
    const title = titleMatch ? titleMatch[1] : '';

    // Extract text content (strip HTML tags)
    const text = html
        .replace(/<script[\s\S]*?<\/script>/gi, '')
        .replace(/<style[\s\S]*?<\/style>/gi, '')
        .replace(/<[^>]*>/g, ' ')
        .replace(/\s+/g, ' ')
        .trim();

    documents.push({
        id: relativePath,
        title,
        url: '/' + relativePath.replace(/index\.html$/, ''),
        text: text.substring(0, 5000),
    });
});

// Generate the search index as JSON
const searchData = {
    documents,
    index: null, // Built on client side
};

fs.writeFileSync(
    path.join(outputDir, 'data.json'),
    JSON.stringify(searchData)
);

console.log(`Indexed ${documents.length} pages for search.`);
// static/js/search.js — Client-side search
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');

let documents = [];

// Load search data
async function loadSearchIndex() {
    const response = await fetch('/search/data.json');
    const data = await response.json();
    documents = data.documents;
}

// Simple search implementation
function search(query) {
    const terms = query.toLowerCase().split(' ').filter(Boolean);

    return documents
        .map(doc => {
            const text = doc.text.toLowerCase();
            const title = doc.title.toLowerCase();

            // Score based on matches
            let score = 0;
            terms.forEach(term => {
                if (title.includes(term)) score += 10;
                if (text.includes(term)) score += 1;

                // Count occurrences
                const titleMatches = (title.match(new RegExp(term, 'g')) || []).length;
                const textMatches = (text.match(new RegExp(term, 'g')) || []).length;
                score += titleMatches * 5 + textMatches * 0.5;
            });

            return { ...doc, score };
        })
        .filter(doc => doc.score > 0)
        .sort((a, b) => b.score - a.score)
        .slice(0, 10);
}

// Handle search input
searchInput.addEventListener('input', (e) => {
    const query = e.target.value.trim();

    if (query.length < 2) {
        searchResults.innerHTML = '';
        searchResults.classList.remove('active');
        return;
    }

    const results = search(query);

    searchResults.innerHTML = results
        .map(result => `
            <a href="${result.url}" class="search-result">
                <h3>${result.title}</h3>
                <p>${result.text.substring(0, 150)}...</p>
            </a>
        `)
        .join('');

    searchResults.classList.add('active');
});

loadSearchIndex();

Pagefind Integration

// package.json — Pagefind with Hugo/11ty
{
    "scripts": {
        "build": "hugo --minify && npx pagefind --source public",
        "dev": "hugo server & npx pagefind --source public --serve"
    }
}

// _layouts/default.html — Pagefind UI
<script src="/pagefind/pagefind-ui.js"></script>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet">

<div id="search"></div>
<script>
    window.addEventListener('DOMContentLoaded', (event) => {
        new PagefindUI({
            element: '#search',
            showSubResults: true,
            showImages: false,
            resetStyles: false,
            bundlePath: '/pagefind/',
            highlightParam: 'highlight'
        });
    });
</script>
// Pagefind generates the index automatically.
// No custom scripts needed — just add the UI element.
// scripts/push-to-algolia.js — Build-time indexing
const algoliasearch = require('algoliasearch');
const glob = require('glob');
const fs = require('fs');

const client = algoliasearch(
    process.env.ALGOLIA_APP_ID,
    process.env.ALGOLIA_ADMIN_KEY
);
const index = client.initIndex('content');

// Collect and index content
const files = glob.sync('./public/**/*.html');
const records = [];

files.forEach((filePath, i) => {
    const html = fs.readFileSync(filePath, 'utf8');
    const relativePath = filePath.replace('./public/', '');

    const title = html.match(/<title>(.*?)<\/title>/)?.[1] || '';
    const text = html
        .replace(/<script[\s\S]*?<\/script>/gi, '')
        .replace(/<style[\s\S]*?<\/style>/gi, '')
        .replace(/<[^>]*>/g, ' ')
        .replace(/\s+/g, ' ')
        .trim()
        .substring(0, 8000);

    records.push({
        objectID: relativePath,
        title,
        url: '/' + relativePath.replace(/index\.html$/, ''),
        content: text,
        lastUpdated: new Date().toISOString(),
    });
});

// Send to Algolia in batches
index
    .saveObjects(records, { batchSize: 100 })
    .then(({ objectIDs }) => {
        console.log(`Indexed ${objectIDs.length} records to Algolia`);
    })
    .catch(err => console.error('Algolia indexing failed:', err));

Common Mistakes

  1. Only indexing page titles, not content. Title-only search misses most content. Include body text, headings, and metadata for comprehensive search results.
  2. Not debouncing search input. Every keystroke triggers a search. Debounce (300ms delay) to reduce computation and improve user experience.
  3. Serving the full content index on every page load. Large indexes (10MB+) hurt performance. Load the index lazily after the page renders.
  4. Ignoring search result relevance. Simple string matching returns poor results. Implement scoring (title matches weigh more) for better relevance.
  5. Not handling empty or error states. Show helpful messages when search returns no results. Handle network errors for hosted search solutions.

Practice Questions

  1. Why do static sites need client-side search instead of server-side?
  2. How does Lunr.js differ from Pagefind for static site search?
  3. What is the advantage of using Algolia over client-side search?
  4. How do you generate a search index during the SSG build process?
  5. How do you score and rank search results for relevance?

Challenge: Add search to a static site: generate a JSON search index during the build, implement a client-side search UI with debouncing, score results by relevance, and display results with highlighted matches.

FAQ

Does client-side search work without JavaScript?

No. Client-side search requires JavaScript to process the query and display results. Provide a server-side fallback or a sitemap for non-JS users.

How large can a search index be?

Keep indexes under 5MB for acceptable load time. For larger sites, split indexes by section or use a hosted solution like Algolia.

Can I exclude certain pages from search results?

Yes. Add metadata to pages indicating they should be excluded. Filter these out during index generation.

How do I handle search for multilingual sites?

Generate separate indexes per language. The search UI should detect the current language and query the appropriate index.

Is Pagefind free to use?

Yes. Pagefind is open-source and free. It generates a static search index and provides a UI component. No external API calls needed.

Mini Project

Add search to a 50+ page static documentation site: use Pagefind for zero-config indexing, customize the search UI with your site's design, implement search result highlighting, add keyboard shortcuts (Ctrl+K) to focus search, and test with 10+ queries.

What's Next

Your site has search. Now learn about SSG Image Optimization to ensure images don't slow down your fast static pages.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro