Introduction
Static websites are fast, secure, and easy to deploy, but they traditionally lack dynamic features like search. MiniSearch changes that by providing a powerful, lightweight JavaScript library that enables full-text search entirely on the client side. With features like fuzzy matching, auto-suggestions, and field boosting, it rivals server-side search solutions while maintaining the simplicity of static sites.
Why MiniSearch?
The Challenge
Traditional search solutions require: - Server-side processing - Database queries - API endpoints - Additional hosting costs - Increased complexity
The MiniSearch Solution
MiniSearch offers: - Pure client-side operation: No server required - Tiny footprint: ~20KB minified and gzipped - Lightning fast: Searches execute in milliseconds - Rich features: Fuzzy search, highlighting, faceted search - Zero dependencies: Works everywhere JavaScript runs
Getting Started
Installation
You have three options for including MiniSearch:
Option 1: NPM (Recommended for build tools)
npm install minisearch
Option 2: CDN
<script src="https://unpkg.com/minisearch@6.1.0/dist/umd/index.min.js"></script>
Option 3: Download and self-host
<script src="/js/minisearch.min.js"></script>
Basic Setup
Create a simple search implementation:
// Initialize MiniSearch
const miniSearch = new MiniSearch({
fields: ['title', 'content', 'tags'], // fields to index
storeFields: ['title', 'content', 'url'], // fields to return
searchOptions: {
boost: { title: 2 }, // boost title matches
fuzzy: 0.2 // enable fuzzy matching
}
});
// Your content to index
const documents = [
{
id: 1,
title: 'Getting Started with JavaScript',
content: 'JavaScript is a versatile programming language...',
tags: ['javascript', 'programming', 'web'],
url: '/blog/javascript-basics'
},
{
id: 2,
title: 'CSS Grid Layout Guide',
content: 'CSS Grid is a powerful layout system...',
tags: ['css', 'design', 'layout'],
url: '/blog/css-grid'
}
// ... more documents
];
// Index all documents
miniSearch.addAll(documents);
// Perform a search
const results = miniSearch.search('javascript guide');
console.log(results);
Building a Complete Search Interface
Let's create a fully functional search interface for your static site:
HTML Structure
<div class="search-container">
<div class="search-box">
<input
type="text"
id="search-input"
placeholder="Search articles..."
autocomplete="off"
/>
<button id="search-btn">
<svg><!-- search icon --></svg>
</button>
</div>
<div id="search-suggestions" class="suggestions"></div>
<div id="search-results" class="results"></div>
</div>
CSS Styling
.search-container {
position: relative;
max-width: 600px;
margin: 0 auto;
}
.search-box {
display: flex;
border: 2px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.3s;
}
.search-box:focus-within {
border-color: #4285f4;
}
#search-input {
flex: 1;
padding: 12px 16px;
border: none;
outline: none;
font-size: 16px;
}
#search-btn {
padding: 0 16px;
background: #4285f4;
border: none;
color: white;
cursor: pointer;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 0 0 8px 8px;
max-height: 300px;
overflow-y: auto;
display: none;
z-index: 1000;
}
.suggestions.active {
display: block;
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.suggestion-item:hover {
background: #f5f5f5;
}
.suggestion-item mark {
background: #fff3cd;
font-weight: bold;
}
.results {
margin-top: 2rem;
}
.result-item {
padding: 1.5rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 1rem;
transition: box-shadow 0.3s;
}
.result-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.result-title {
font-size: 1.25rem;
color: #1a73e8;
text-decoration: none;
display: block;
margin-bottom: 0.5rem;
}
.result-excerpt {
color: #5f6368;
line-height: 1.6;
}
.result-excerpt mark {
background: #fff3cd;
padding: 0 2px;
}
.result-meta {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #9aa0a6;
}
Advanced JavaScript Implementation
Create a robust search experience with auto-suggestions and highlighting:
class SiteSearch {
constructor() {
this.miniSearch = new MiniSearch({
fields: ['title', 'content', 'tags', 'excerpt'],
storeFields: ['title', 'excerpt', 'url', 'date', 'category'],
searchOptions: {
boost: { title: 3, tags: 2 },
fuzzy: 0.2,
prefix: true
}
});
this.searchInput = document.getElementById('search-input');
this.suggestionsEl = document.getElementById('search-suggestions');
this.resultsEl = document.getElementById('search-results');
this.init();
}
async init() {
// Load and index content
await this.loadContent();
// Set up event listeners
this.setupEventListeners();
}
async loadContent() {
try {
// Fetch your content index (generated during build)
const response = await fetch('/search-index.json');
const documents = await response.json();
// Index all documents
this.miniSearch.addAll(documents);
console.log(`Indexed ${documents.length} documents`);
} catch (error) {
console.error('Failed to load search index:', error);
}
}
setupEventListeners() {
// Debounced input handler
let debounceTimer;
this.searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
this.handleSearch(e.target.value);
}, 200);
});
// Handle Enter key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.performFullSearch(e.target.value);
}
});
// Handle clicks outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
this.hideSuggestions();
}
});
}
handleSearch(query) {
if (query.length < 2) {
this.hideSuggestions();
return;
}
// Get suggestions
const suggestions = this.miniSearch.autoSuggest(query, {
limit: 5,
fuzzy: 0.2
});
if (suggestions.length > 0) {
this.showSuggestions(suggestions, query);
} else {
this.hideSuggestions();
}
}
showSuggestions(suggestions, query) {
const html = suggestions.map(suggestion => {
const highlighted = this.highlightText(suggestion.suggestion, query);
return `
<div class="suggestion-item" data-term="${suggestion.suggestion}">
${highlighted}
<span class="suggestion-score">(${suggestion.score.toFixed(2)})</span>
</div>
`;
}).join('');
this.suggestionsEl.innerHTML = html;
this.suggestionsEl.classList.add('active');
// Add click handlers to suggestions
this.suggestionsEl.querySelectorAll('.suggestion-item').forEach(item => {
item.addEventListener('click', () => {
const term = item.dataset.term;
this.searchInput.value = term;
this.performFullSearch(term);
this.hideSuggestions();
});
});
}
hideSuggestions() {
this.suggestionsEl.classList.remove('active');
}
performFullSearch(query) {
if (!query) {
this.resultsEl.innerHTML = '';
return;
}
// Search with options
const results = this.miniSearch.search(query, {
limit: 20,
boost: { title: 3, tags: 2 },
fuzzy: 0.2,
prefix: true,
combineWith: 'OR'
});
this.displayResults(results, query);
}
displayResults(results, query) {
if (results.length === 0) {
this.resultsEl.innerHTML = `
<div class="no-results">
<p>No results found for "<strong>${query}</strong>"</p>
<p>Try different keywords or check your spelling</p>
</div>
`;
return;
}
const html = results.map(result => {
const highlighted = this.highlightText(result.excerpt, query);
return `
<article class="result-item">
<a href="${result.url}" class="result-title">
${this.highlightText(result.title, query)}
</a>
<div class="result-excerpt">
${highlighted}
</div>
<div class="result-meta">
<span class="result-category">${result.category}</span>
<span class="result-date">${result.date}</span>
<span class="result-score">Score: ${result.score.toFixed(2)}</span>
</div>
</article>
`;
}).join('');
this.resultsEl.innerHTML = `
<div class="results-header">
Found ${results.length} results for "<strong>${query}</strong>"
</div>
${html}
`;
}
highlightText(text, query) {
if (!text) return '';
// Split query into words
const words = query.toLowerCase().split(/\s+/);
let highlighted = text;
// Highlight each word
words.forEach(word => {
if (word.length > 1) {
const regex = new RegExp(`(${word})`, 'gi');
highlighted = highlighted.replace(regex, '<mark>$1</mark>');
}
});
return highlighted;
}
}
// Initialize search when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new SiteSearch();
});
Generating the Search Index
For static sites, generate the search index during build time:
Using a Build Script
// build-search-index.js
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const glob = require('glob');
function buildSearchIndex() {
const documents = [];
// Find all markdown files
const files = glob.sync('content/**/*.md');
files.forEach((file, index) => {
const content = fs.readFileSync(file, 'utf-8');
const { data, content: body } = matter(content);
// Extract text from markdown
const plainText = body
.replace(/#{1,6}\s/g, '') // Remove headers
.replace(/[*_~`]/g, '') // Remove formatting
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/\n+/g, ' ') // Replace newlines
.trim();
// Create document
documents.push({
id: index + 1,
title: data.title || path.basename(file, '.md'),
content: plainText,
excerpt: plainText.substring(0, 200) + '...',
tags: data.tags || [],
category: data.category || 'General',
date: data.date || new Date().toISOString(),
url: file.replace('content/', '/').replace('.md', '.html')
});
});
// Write index file
fs.writeFileSync(
'public/search-index.json',
JSON.stringify(documents, null, 2)
);
console.log(`Generated search index with ${documents.length} documents`);
}
buildSearchIndex();
Integration with Static Site Generators
Jekyll:
# _plugins/search_index_generator.rb
Jekyll::Hooks.register :site, :post_write do |site|
index = site.posts.docs.map do |post|
{
id: post.id,
title: post.data['title'],
content: post.content.gsub(/<[^>]*>/, ''),
url: post.url,
date: post.date,
tags: post.data['tags'] || []
}
end
File.write('_site/search-index.json', JSON.to_json(index))
end
Hugo:
// layouts/_default/index.json
[
{{ range $index, $page := .Site.RegularPages }}
{{- if $index -}},{{- end }}
{
"id": {{ $index }},
"title": {{ $page.Title | jsonify }},
"content": {{ $page.Plain | jsonify }},
"url": {{ $page.Permalink | jsonify }},
"tags": {{ $page.Params.tags | jsonify }}
}
{{- end }}
]
Performance Optimization
Lazy Loading
Load the search functionality only when needed:
// Lazy load search
let searchInstance = null;
document.getElementById('search-input').addEventListener('focus', async () => {
if (!searchInstance) {
// Load MiniSearch dynamically
const { default: MiniSearch } = await import('minisearch');
// Load search index
const response = await fetch('/search-index.json');
const documents = await response.json();
// Initialize search
searchInstance = new MiniSearch({
fields: ['title', 'content'],
storeFields: ['title', 'url']
});
searchInstance.addAll(documents);
}
});
Index Compression
Compress large search indexes:
// Compress index during build
const zlib = require('zlib');
const compressed = zlib.gzipSync(JSON.stringify(documents));
fs.writeFileSync('public/search-index.json.gz', compressed);
// Decompress in browser
async function loadCompressedIndex() {
const response = await fetch('/search-index.json.gz');
const buffer = await response.arrayBuffer();
const decompressed = pako.inflate(new Uint8Array(buffer), { to: 'string' });
return JSON.parse(decompressed);
}
Incremental Indexing
For large sites, index content incrementally:
// Index in chunks to avoid blocking
async function indexDocuments(documents, chunkSize = 100) {
for (let i = 0; i < documents.length; i += chunkSize) {
const chunk = documents.slice(i, i + chunkSize);
miniSearch.addAll(chunk);
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0));
// Update progress
const progress = Math.round((i / documents.length) * 100);
updateProgressBar(progress);
}
}
Advanced Features
Faceted Search
Implement filtering by categories:
// Search with filters
function searchWithFilters(query, filters = {}) {
let results = miniSearch.search(query);
// Apply category filter
if (filters.category) {
results = results.filter(r => {
const doc = miniSearch.getStoredFields(r.id);
return doc.category === filters.category;
});
}
// Apply date range filter
if (filters.dateFrom || filters.dateTo) {
results = results.filter(r => {
const doc = miniSearch.getStoredFields(r.id);
const date = new Date(doc.date);
if (filters.dateFrom && date < new Date(filters.dateFrom)) {
return false;
}
if (filters.dateTo && date > new Date(filters.dateTo)) {
return false;
}
return true;
});
}
return results;
}
Search Analytics
Track what users search for:
function trackSearch(query, resultCount) {
// Send to analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'search', {
search_term: query,
result_count: resultCount
});
}
// Store locally for insights
const searches = JSON.parse(localStorage.getItem('searches') || '[]');
searches.push({
query,
resultCount,
timestamp: Date.now()
});
// Keep only last 100 searches
if (searches.length > 100) {
searches.shift();
}
localStorage.setItem('searches', JSON.stringify(searches));
}
Instant Search with Web Workers
Offload search to a web worker for better performance:
// search-worker.js
importScripts('https://unpkg.com/minisearch/dist/umd/index.min.js');
let miniSearch = null;
self.addEventListener('message', async (event) => {
const { type, data } = event.data;
switch (type) {
case 'init':
miniSearch = new MiniSearch(data.options);
miniSearch.addAll(data.documents);
self.postMessage({ type: 'ready' });
break;
case 'search':
const results = miniSearch.search(data.query, data.options);
self.postMessage({ type: 'results', results });
break;
}
});
// Main thread
const searchWorker = new Worker('/search-worker.js');
searchWorker.postMessage({
type: 'init',
data: { options: searchOptions, documents }
});
function search(query) {
searchWorker.postMessage({
type: 'search',
data: { query, options: {} }
});
}
searchWorker.addEventListener('message', (event) => {
if (event.data.type === 'results') {
displayResults(event.data.results);
}
});
Troubleshooting Common Issues
Issue: Search returns no results
Solution: Check field names match between indexing and documents:
// Ensure fields exist in documents
documents.forEach(doc => {
console.log('Document fields:', Object.keys(doc));
});
Issue: Fuzzy search not working
Solution: Adjust fuzzy threshold:
// More permissive fuzzy search
const results = miniSearch.search(query, {
fuzzy: term => term.length > 3 ? 0.3 : null
});
Issue: Large index size
Solution: Index only necessary fields:
// Selective indexing
const documents = posts.map(post => ({
id: post.id,
title: post.title,
// Index only first 500 characters
content: post.content.substring(0, 500),
url: post.url
}));
Conclusion
MiniSearch transforms static websites into dynamic, searchable experiences without sacrificing simplicity or performance. By implementing client-side search, you maintain the benefits of static hosting while providing users with powerful search capabilities typically reserved for dynamic applications.
The combination of fuzzy matching, auto-suggestions, and instant results creates a search experience that rivals major platforms, all while running entirely in the browser. Whether you're building a blog, documentation site, or e-commerce platform, MiniSearch scales to meet your needs.
Start small with basic search functionality, then progressively enhance with advanced features as your site grows. Your users will appreciate the instant, accurate search results, and you'll appreciate the simplicity of implementation and maintenance.