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.