SSG Search — Adding Search Functionality to Static Sites
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
Lunr.js Client-Side Search
// 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.
Algolia Hosted Search
// 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
- Only indexing page titles, not content. Title-only search misses most content. Include body text, headings, and metadata for comprehensive search results.
- Not debouncing search input. Every keystroke triggers a search. Debounce (300ms delay) to reduce computation and improve user experience.
- Serving the full content index on every page load. Large indexes (10MB+) hurt performance. Load the index lazily after the page renders.
- Ignoring search result relevance. Simple string matching returns poor results. Implement scoring (title matches weigh more) for better relevance.
- Not handling empty or error states. Show helpful messages when search returns no results. Handle network errors for hosted search solutions.
Practice Questions
- Why do static sites need client-side search instead of server-side?
- How does Lunr.js differ from Pagefind for static site search?
- What is the advantage of using Algolia over client-side search?
- How do you generate a search index during the SSG build process?
- 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
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