
Static Search Feature with Astro and React
Building a fast, accessible search feature for a static site can be challenging. You want the performance benefits of static generation, but you also need dynamic search functionality. In this post, I’ll walk you through implementing a production-ready static search feature using Astro’s build-time API endpoints and React islands.
The Challenge
Traditional search implementations either require a backend service or client-side JavaScript that loads all content upfront. For a static blog, we needed something that:
- Generates search indices at build time
- Provides instant, responsive search
- Maintains accessibility standards
- Works without external dependencies
- Degrades gracefully without JavaScript
The Solution: Build-Time Index + React Island
Our approach combines Astro’s powerful build-time capabilities with React’s interactivity where it matters most.
Architecture Overview
Build Time:
├── Astro API endpoint processes blog collection
├── Generates optimized JSON search index
└── Prerenders static files
Runtime:
├── React island hydrates on user interaction
├── Fetches pregenerated search index
└── Provides instant search with keyboard navigation
Implementation Steps
1. Build-Time Search Index Generation
First, we created an Astro API endpoint that processes our blog collection at build time:
// src/pages/api/search-index.json.ts
import { getCollection } from "astro:content";
export const prerender = true;
export async function GET() {
const posts = await getCollection("blog");
const searchIndex = posts.map((post) => {
// Extract first paragraph for description if not provided
const description =
post.data.description ||
(post.body || "")
.replace(/^---[\s\S]*?---/, "") // Remove frontmatter
.replace(/^#+\s.*$/gm, "") // Remove headers
.replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Convert links to text
.trim()
.split("\n")[0] // First paragraph
.substring(0, 150) + "...";
return {
title: post.data.title,
url: `/blog/${post.id}/`,
description: description.replace(/\.\.\.$/, "").trim() || post.data.title,
pubDate: post.data.pubDate?.toISOString() || new Date().toISOString(),
tags: post.data.tags || [],
};
});
// Sort by publication date (newest first)
searchIndex.sort(
(a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
);
return new Response(JSON.stringify(searchIndex, null, 2), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=86400", // 24 hour cache
},
});
}
The prerender: true
directive ensures this endpoint runs at build time, generating a static JSON file that’s served directly from your CDN.
2. React Search Component
Next, we built a React component that provides the interactive search experience:
// src/components/SearchBar.tsx
import React, { useState, useEffect, useRef, useMemo } from "react";
interface SearchItem {
title: string;
url: string;
description?: string;
pubDate?: string;
tags?: string[];
}
export default function SearchBar({
indexUrl = "/api/search-index.json",
placeholder = "Search posts…",
minChars = 2,
maxResults = 8,
}) {
const [query, setQuery] = useState("");
const [items, setItems] = useState<SearchItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
// Lazy fetch search index on first interaction
const fetchData = async () => {
try {
const response = await fetch(indexUrl);
const data = await response.json();
// Cache in memory for subsequent searches
dataRef.current = data;
} catch (error) {
console.error("Search index fetch failed:", error);
}
};
// Debounced search with ranking
const performSearch = (searchQuery: string) => {
const normalizedQuery = searchQuery.toLowerCase();
const results = [];
dataRef.current.forEach((item) => {
let score = 0;
// Title matches score highest
if (item.title.toLowerCase().includes(normalizedQuery)) score += 3;
// Description matches score medium
if (item.description?.toLowerCase().includes(normalizedQuery)) score += 2;
// Tag matches score lowest
if (item.tags?.some(tag => tag.toLowerCase().includes(normalizedQuery))) score += 1;
if (score > 0) {
results.push({ ...item, score });
}
});
// Sort by relevance, then by date
results.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
return new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime();
});
setItems(results.slice(0, maxResults));
};
// Full implementation includes keyboard navigation,
// accessibility features, and click handlers...
}
3. Accessibility & UX Features
The search component includes comprehensive accessibility support:
// Proper ARIA labeling
<label htmlFor="search-input" className="sr-only">
Search blog posts
</label>
<input
id="search-input"
role="combobox"
aria-expanded={isOpen}
aria-controls="search-results"
aria-autocomplete="list"
aria-activedescendant={
selectedIndex >= 0 ? `search-item-${selectedIndex}` : undefined
}
/>
// Keyboard navigation
const handleKeyDown = (e) => {
switch (e.key) {
case "ArrowDown":
// Navigate down through results
case "ArrowUp":
// Navigate up through results
case "Enter":
// Navigate to selected result
case "Escape":
// Close search and clear
}
};
4. Astro Integration
Finally, we integrated the React component as an Astro island:
---
// src/pages/blog/index.astro
import SearchBar from "../../components/SearchBar.tsx";
---
<html>
<body>
<!-- Static blog listing -->
<main>
<!-- Search bar hydrates only when needed -->
<SearchBar client:load />
<!-- Rest of the blog page... -->
</main>
</body>
</html>
Performance Benefits
This approach delivers excellent performance characteristics:
- Build time: Search index generated once during build
- First load: No JavaScript execution required for initial page render
- Interaction: React island hydrates only when search is focused
- Subsequent searches: In-memory cache eliminates additional network requests
- SEO: Fully static HTML with no client-side rendering blocking
TypeScript Support
We added proper TypeScript support with content collection schemas:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
heroImage: image().optional(),
tags: z.array(z.string()).optional(), // For search functionality
}),
});
Testing Strategy
We implemented comprehensive Playwright tests covering:
- Search input rendering and accessibility
- Debounced search behavior
- Keyboard navigation (arrow keys, enter, escape)
- Click-to-navigate functionality
- “No results found” states
- Outside click handling
// tests/search.spec.ts
test('should show search results when typing', async ({ page }) => {
await page.goto('/blog');
await page.fill('[data-testid="search-input"]', 'github');
await page.waitForSelector('[data-testid="search-results"]');
const results = await page.locator('[data-testid="search-item"]');
expect(await results.count()).toBeGreaterThan(0);
});
Lessons Learned
React 19 Compatibility
We initially hit hydration issues with React 19 and Astro’s dev runtime. The solution was pinning React to version 18.3.1 in package.json until the integration matures.
TypeScript Configuration
Astro requires specific TypeScript settings for React components:
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "react",
"esModuleInterop": true
}
}
Content Processing
When extracting descriptions from markdown content, we had to carefully strip frontmatter, headers, and images to get clean preview text.
Conclusion
This static search implementation provides the best of both worlds: the performance and SEO benefits of static generation with the user experience of dynamic search. The build-time index generation ensures fast searches without sacrificing load times, while the React island provides progressive enhancement for interactive features.
The result is a search feature that loads instantly, works without JavaScript, and provides a smooth, accessible experience for all users.
Key Takeaways
- Prerender search indices at build time for optimal performance
- Use React islands for progressive enhancement
- Implement proper accessibility with ARIA labels and keyboard navigation
- Test thoroughly with both unit and end-to-end tests
- Consider React version compatibility with your Astro setup
This approach scales well and could easily be extended to support multiple content types, advanced filtering, or even full-text search capabilities.