The Evolution Challenge
Documentation search is one of those features that seems simple until you try to build it well. Users expect instant, relevant results across thousands of pages of content. They want search to understand context, handle typos, and surface related information—all while being fast enough to provide real-time feedback.
At Mastra, our documentation search went through three major evolutions:
- Basic text matching - Fast but limited relevance
- Enhanced UI with performance optimizations - Better UX but still basic search
- AI-powered search with Algolia - Intelligent, contextual, and fast
Here's the technical journey of how we built a search system that developers actually want to use.
The Performance Problem
Our first major challenge wasn't relevance—it was performance. With thousands of documentation pages and real-time search feedback, we were hitting serious bottlenecks:
Virtual Scrolling Implementation
When search results grew large, our UI became sluggish. We implemented virtual scrolling to handle hundreds of results smoothly:
// Virtual scrolling hook for large result sets
export function useVirtualizedResults(items: any[], containerHeight: number, itemHeight: number) {
const [scrollTop, setScrollTop] = useState(0);
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = Math.min(
visibleStart + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
const visibleItems = items.slice(visibleStart, visibleEnd);
const totalHeight = items.length * itemHeight;
const offsetY = visibleStart * itemHeight;
return {
visibleItems,
totalHeight,
offsetY,
onScroll: (e: React.UIEvent) => setScrollTop(e.currentTarget.scrollTop)
};
}
Debounced Search Implementation
Real-time search was overwhelming our servers. We implemented smart debouncing with request cancellation:
export function useDebouncedSearch(
searchTerm: string,
delay: number = 300,
searchFn: (query: string, signal: AbortSignal) => Promise<any>
) {
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Clear previous timer and cancel previous request
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (!searchTerm.trim()) {
setResults([]);
setIsLoading(false);
return;
}
setIsLoading(true);
abortControllerRef.current = new AbortController();
debounceTimerRef.current = setTimeout(async () => {
try {
const results = await searchFn(searchTerm, abortControllerRef.current!.signal);
if (!abortControllerRef.current!.signal.aborted) {
setResults(results);
setIsLoading(false);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
setIsLoading(false);
}
}
}, delay);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [searchTerm, delay, searchFn]);
return { results, isLoading };
}
This approach eliminated redundant requests and provided smooth user experience even with aggressive typing.
The Algolia Migration
While performance improvements helped, we needed smarter search. Basic text matching couldn't handle:
- Typos and variations in technical terms
- Contextual relevance (searching "workflow" should prioritize workflow documentation)
- Multi-language content with proper locale filtering
- Faceted search across different content types
We migrated to Algolia, but building the integration properly required solving several complex problems.
Algolia Search Hook Architecture
Our Algolia integration needed to handle multiple concerns: debouncing, request cancellation, result transformation, and error handling:
export function useAlgoliaSearch(
debounceTime = 300,
searchOptions?: AlgoliaSearchOptions,
): UseAlgoliaSearchResult {
const [isSearchLoading, setIsSearchLoading] = useState(false);
const [results, setResults] = useState<AlgoliaResult[]>([]);
const [search, setSearch] = useState("");
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Initialize Algolia client
const algoliaClient = useRef<SearchClient | null>(null);
useEffect(() => {
// Initialize Algolia client with credentials
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY;
if (appId && apiKey) {
algoliaClient.current = algoliasearch(appId, apiKey);
} else {
console.warn(
"Algolia credentials not found. Please set NEXT_PUBLIC_ALGOLIA_APP_ID and NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY environment variables.",
);
}
}, []);
Smart Result Transformation
One of our biggest challenges was transforming Algolia's raw results into a format that worked well for documentation:
// Transform Algolia results to match our expected format
const transformedResults: AlgoliaResult[] = firstResult.hits.map((hit) => {
const typedHit = hit as AlgoliaHit;
// Helper function to extract relevant snippet around search terms
const extractRelevantSnippet = (
content: string,
searchTerm: string,
maxLength: number = 200,
): string => {
if (!content || !searchTerm)
return content?.substring(0, maxLength) + "..." || "";
const lowerContent = content.toLowerCase();
const lowerSearchTerm = searchTerm.toLowerCase();
const searchWords = lowerSearchTerm
.split(/\s+/)
.filter((word) => word.length > 2);
// Find the first occurrence of any search word
let bestIndex = -1;
for (const word of searchWords) {
const index = lowerContent.indexOf(word);
if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
bestIndex = index;
}
}
if (bestIndex === -1) {
return content.substring(0, maxLength) + "...";
}
// Extract snippet around the found term
const start = Math.max(0, bestIndex - 50);
const end = Math.min(content.length, start + maxLength);
let snippet = content.substring(start, end);
// Clean up the snippet
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet = snippet + "...";
return snippet;
};
// Prioritize snippet result, then highlighted content, then fallback
let excerpt = "";
if (typedHit._snippetResult?.content?.value) {
// Use Algolia's snippet if available
excerpt = typedHit._snippetResult.content.value;
} else if (typedHit._highlightResult?.content?.value) {
// Use highlighted content and extract relevant snippet
const highlightedContent = typedHit._highlightResult.content.value;
excerpt = extractRelevantSnippet(highlightedContent, search, 200);
} else if (typedHit.content) {
// Fallback to extracting snippet from raw content
excerpt = extractRelevantSnippet(typedHit.content, search, 200);
} else if (typedHit._highlightResult?.title?.value) {
excerpt = typedHit._highlightResult.title.value;
} else {
excerpt = typedHit.title || "";
}
return {
objectID: typedHit.objectID,
title: typedHit.title || "",
excerpt: excerpt,
url: typedHit.url || "",
_highlightResult: typedHit._highlightResult,
_snippetResult: typedHit._snippetResult,
sub_results: createSubResults(typedHit, search),
};
});
Hierarchical Results with Sub-Results
Documentation often has hierarchical structure—pages with sections, subsections, and anchors. We needed search results that reflected this structure:
// Create multiple sub_results if we have hierarchy or can detect sections
const subResults: AlgoliaResult["sub_results"] = [];
if (typedHit.hierarchy && Array.isArray(typedHit.hierarchy)) {
// If we have hierarchy information, create sub-results for different sections
typedHit.hierarchy.forEach((section: AlgoliaHierarchySection) => {
if (
section.content &&
section.content.toLowerCase().includes(search.toLowerCase())
) {
subResults.push({
title: section.title || typedHit.title || "",
excerpt: extractRelevantSnippet(section.content, search, 180),
url: `${typedHit.url}${section.anchor ? `#${section.anchor}` : ""}`,
});
}
});
// If no hierarchy sections matched, add the main result
if (subResults.length === 0) {
subResults.push({
title: typedHit.title || "",
excerpt: excerpt,
url: typedHit.url || "",
});
}
} else {
// Single sub-result with the main excerpt
subResults.push({
title: typedHit.title || "",
excerpt: excerpt,
url: typedHit.url || "",
});
}
This creates a rich search experience where users can jump directly to relevant sections within pages.
Advanced Search Configuration
Algolia's power comes from its configuration flexibility. Here's how we optimized for documentation search:
const searchRequest = {
indexName: indexName,
query: search,
params: {
hitsPerPage: searchOptions?.hitsPerPage || 20,
attributesToRetrieve: searchOptions?.attributesToRetrieve || [
"title",
"content",
"url",
"hierarchy",
],
attributesToHighlight: searchOptions?.attributesToHighlight || [
"title",
"content",
],
attributesToSnippet: searchOptions?.attributesToSnippet || [
"content:15",
],
highlightPreTag: searchOptions?.highlightPreTag || "<mark>",
highlightPostTag: searchOptions?.highlightPostTag || "</mark>",
snippetEllipsisText: searchOptions?.snippetEllipsisText || "…",
...(searchOptions?.filters && { filters: searchOptions.filters }),
...(searchOptions?.facetFilters && {
facetFilters: searchOptions.facetFilters,
}),
},
};
Locale-Specific Search
With international documentation, we needed search results that respect user locale:
// Automatic locale filtering
const getLocaleFilter = (pathname: string): string => {
const localeMatch = pathname.match(/^\/([a-z]{2}(-[A-Z]{2})?)\//);
const locale = localeMatch ? localeMatch[1] : 'en';
return `locale:${locale}`;
};
// Usage in search
const searchWithLocale = useAlgoliaSearch(300, {
indexName: "mastra_docs",
filters: getLocaleFilter(router.pathname),
facetFilters: [["type:docs", "type:reference", "type:examples"]]
});
This ensures Japanese users see Japanese documentation first, while English users get English results.
Mobile-First Performance
Mobile users need fast, efficient search. We implemented several mobile-specific optimizations:
Responsive Search UI
const SearchWrapper = () => {
const [isSearchVisible, setIsSearchVisible] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<div className="search-wrapper">
{isMobile ? (
<button
onClick={()=> setIsSearchVisible(true)}
className="search-toggle-btn"
>
🔍 Search
</button>
) : (
<SearchInput />
)}
{(isSearchVisible && isMobile) && (
<Modal onClose={()=> setIsSearchVisible(false)}>
<SearchInput />
<VirtualizedResults />
</Modal>
)}
</div>
);
};
Reduced Data Transfer
Mobile search results use abbreviated content:
const getMobileOptimizedExcerpt = (content: string, maxLength: number = 120): string => {
if (content.length <= maxLength) return content;
// Find the last complete sentence within the limit
const truncated = content.substring(0, maxLength);
const lastSentence = truncated.lastIndexOf('.');
if (lastSentence > maxLength * 0.7) {
return truncated.substring(0, lastSentence + 1);
}
return truncated + "...";
};
Error Handling and Fallbacks
Production search needs robust error handling:
try {
const { results } = await algoliaClient.current.search([searchRequest]);
// Check if request was cancelled
if (signal.aborted) return;
const firstResult = results[0];
if ("hits" in firstResult) {
setResults(transformResults(firstResult.hits));
} else {
setResults([]);
}
} catch (error) {
// Ignore AbortError from request cancellation
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
// Log other errors but don't break the UI
if (!signal.aborted) {
console.error("Algolia search error:", error);
setIsSearchLoading(false);
setResults([]);
// Optional: Fallback to local search
tryLocalSearchFallback(searchTerm);
}
}
Analytics and Insights
To improve search relevance, we track search patterns:
const trackSearchEvent = (query: string, results: number, clickedResult?: string) => {
analytics.track('Search Performed', {
query,
results_count: results,
clicked_result: clickedResult,
timestamp: Date.now(),
user_locale: navigator.language,
});
};
// Track in the search hook
useEffect(() => {
if (results.length > 0 && search) {
trackSearchEvent(search, results.length);
}
}, [results, search]);
Performance Metrics
Our optimizations resulted in significant improvements:
Before Optimizations
- Search response time: 800-1200ms
- UI freeze time: 200-400ms with large result sets
- Mobile bounce rate: 35% from search page
- Search success rate: 60% (users finding what they need)
After Optimizations
- Search response time: 150-300ms
- UI freeze time: 0ms (virtual scrolling)
- Mobile bounce rate: 12% from search page
- Search success rate: 82% with AI-powered relevance
The Architecture Benefits
This search architecture provides several key advantages:
1. Scalability
- Virtual scrolling handles unlimited results
- Debounced requests reduce server load
- Algolia scales automatically with traffic
2. Relevance
- AI-powered ranking understands context
- Hierarchical results show section-level matches
- Typo tolerance and synonym matching
3. Performance
- Sub-300ms response times
- Smooth UI with no freezing
- Optimized mobile experience
4. Maintainability
- Clean separation of concerns
- Type-safe interfaces
- Comprehensive error handling
What We Learned
Building production search taught us several important lessons:
1. Performance is a Feature
Fast search changes user behavior. Users search more frequently and explore more content when results appear instantly.
2. Context Matters More Than Matching
Users don't just want text matches—they want relevant results that understand their intent and context.
3. Mobile Requires Different Thinking
Mobile search isn't just responsive desktop search. It needs different UI patterns, performance characteristics, and data optimization.
4. Observability is Essential
Without analytics, you're optimizing blind. Track queries, result clicks, and abandonment to improve relevance.
Future Enhancements
We're working on several advanced search features:
AI-Powered Query Understanding
const enhanceQuery = async (userQuery: string): Promise<string> => {
const enhancement = await llm.generate(`
Enhance this documentation search query for better results:
"${userQuery}"
Consider:
- Technical synonyms
- Related concepts
- Common misspellings
- Context clues
Return enhanced query:
`);
return enhancement.text;
};
Personalized Results
Track user behavior to customize search ranking for individual users based on their role (frontend dev, backend dev, etc.).
Voice Search Integration
const useVoiceSearch = () => {
const recognition = new (window as any).webkitSpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
const startListening = () => {
recognition.start();
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
performSearch(transcript);
};
};
return { startListening };
};
Building advanced search architecture isn't just about the technology—it's about understanding user behavior and creating experiences that help people find what they need quickly and effectively.
The combination of performance optimization, AI-powered relevance, and thoughtful UX design has transformed how developers interact with our documentation. Search has become a primary navigation method rather than a last resort.