How I Added Search to My Static Site

My journey implementing client-side search with MiniSearch, including the challenges I faced and practical solutions I discovered along the way.

Credit: @victoriaromulo / unsplash.com
Credit: @victoriaromulo / unsplash.com

Have you ever had one of those moments where you start a “simple” coding task, only to discover layers of complexity you never imagined? That’s exactly what happened when I decided to add search to my static site.

The requirements seemed straightforward: help readers find content quickly, make it work without a server, and keep it lightweight. Easy enough, right? “I’ll just use Meilisearch,” I thought to myself. I’d used it before on larger projects and loved its powerful features and great documentation. The plan seemed simple: spin up a Meilisearch instance, index my blog posts, and call it a day.

But as I started setting up the server, something felt off. I was configuring API keys, planning server maintenance, and calculating potential costs - all for a personal blog with a handful of posts. It felt like using a sledgehammer to crack a nut.

That’s when I stumbled upon MiniSearch. At just 8KB minified and gzipped, with no external dependencies and completely client-side operation, it seemed too good to be true. I decided to give it a shot. Little did I know this “simple” switch would lead me down a rabbit hole of text processing, token positions, and UI challenges that would reshape how I think about search implementations.

Three days, countless Stack Overflow visits, and several “aha!” moments later, I had learned more about search implementation than I ever expected. This is that story - how a “quick feature” turned into a deep dive into search implementation, and how I eventually built a fast, efficient search system using MiniSearch, SolidJS, and some creative problem-solving. Let me take you through the journey.

💡
Protip

The best learning experiences often come from underestimating a problem’s complexity. Embrace the challenge - it’s where the real growth happens.

The Component Dance: Astro Meets SolidJS

My first challenge was integrating search into my Astro site. I wanted the search UI to be fast and responsive, which meant using a reactive framework. I chose SolidJS for its performance and small bundle size. But how do you seamlessly blend a static site generator with a reactive UI library?

The answer was a layered component approach. First, I created a simple Astro component as the static shell:

---
// SearchInput.astro
import SearchWrapper from './SearchWrapper';

interface Props {
  placeholder?: string;
  class?: string;
}

const { placeholder = "Search", class: className } = Astro.props;
---

<div class={`relative ${className}`}>
  <label class="relative block">
    <span class="sr-only">{placeholder}</span>
    <SearchWrapper client:only="solid-js" placeholder={placeholder} />
  </label>
</div>
💡
Protip

Use Astro’s client directives strategically. The client:only directive tells Astro to skip server-rendering and only render the component on the client, perfect for purely interactive components.

The Brain of the Operation: Search Logic

The real magic happens in the SearchInputLogic component. This is where user interactions, search state, and result rendering all come together:

const SearchInputLogic: Component<Props> = (props) => {
  const store = useSearch();
  const [isOpen, setIsOpen] = createSignal(false);
  let searchRef: HTMLDivElement | undefined;

  // Handle clicks outside search
  const handleClickOutside = (event: MouseEvent) => {
    if (searchRef && !searchRef.contains(event.target as Node)) {
      setIsOpen(false);
    }
  };

  onMount(() => {
    if (typeof window !== 'undefined') {
      window.addEventListener('mousedown', handleClickOutside);

      // Handle Astro view transitions
      document.addEventListener('astro:after-swap', () => {
        store.setIsNavigating(false);
      });
    }
  });

  return (
    <div ref={searchRef} class="w-full">
      <div class="relative">
        <input
          type="search"
          value={store.query()}
          onInput={handleInput}
          placeholder={props.placeholder}
          class="search-input"
          role="searchbox"
          autocomplete="off"
          spellcheck={false}
        />
        <Show when={store.isNavigating()} fallback={<SearchIcon />}>
          <LoadingSpinner />
        </Show>
      </div>

      <Show when={isOpen() && store.query().trim().length >= 3}>
        <SearchResults />
      </Show>
    </div>
  );
};
💡
Protip

Pay attention to the little details - loading states, transitions, and edge cases all contribute to a polished user experience.

The Secret Sauce: Text Processing

The most challenging part wasn’t the UI - it was making the search results accurate and useful. Here’s how I process the text before indexing:

function cleanText(text: string): string {
  return (
    text
      // Remove import statements
      .replace(/^import\s+.*?from\s+['"].*?['"];?\s*$/gm, '')
      // Remove image markdown
      .replace(/!\[.*?\]\(.*?\)/g, '')
      // Remove markdown syntax
      .replace(/[#*`_~\[\]]/g, '')
      // Remove empty lines
      .replace(/\n\s*\n/g, '\n')
      // Normalize whitespace
      .replace(/\s+/g, ' ')
      .trim()
  );
}

function tokenizeWithPositions(text: string): TokenPosition[] {
  const tokens: TokenPosition[] = [];
  const regex = /\S+/g;
  let match;

  while ((match = regex.exec(text)) !== null) {
    tokens.push({
      token: match[0].toLowerCase(),
      start: match.index,
      end: match.index + match[0].length,
    });
  }

  return tokens;
}
💡
Protip

Clean your text before indexing, not during display. This ensures your search index and display text stay perfectly aligned.

To convert our blog posts into searchable documents, I created a helper function:

export function blogToSearchableDocuments(
  posts: CollectionEntry<'blog'>[]
): SearchableDocument[] {
  return posts.map((post) => ({
    id: post.id,
    title: cleanText(post.data.title),
    excerpt: cleanText(post.data.excerpt || ''),
    content: cleanText(post.body),
    url: `/blog/${post.slug}`,
  }));
}

This ensures all our content is properly cleaned and structured before being added to the search index. The combination of careful text processing and thoughtful indexing configuration helps create a search experience that feels natural and returns relevant results.

Making Search Results Beautiful and Useful

The search results needed to be more than just a list of matches. I wanted to show context around matched terms, highlight the matches themselves, and make it all look good:

<div class="absolute z-modal mt-2 w-full rounded-lg border bg-surface-primary shadow-lg">
  <Show when={!store.isLoading()}>
    <Show when={store.results().length > 0}>
      <div class="divide-y divide-border">
        <For each={store.results()}>
          {(result: SearchResult) => (
            <a
              href={result.url}
              onClick={handleResultClick}
              class="block p-3 transition-colors hover:bg-surface-hover"
            >
              {/* Title with highlighting */}
              <div class="text-primary font-medium">
                {result.matches.title.positions.length > 0 ? (
                  <span
                    innerHTML={getSnippet(
                      result.title,
                      result.matches.title.positions[0]
                    )}
                  />
                ) : (
                  result.title
                )}
              </div>

              {/* Excerpt with highlighting */}
              <div class="text-secondary mt-1 text-sm">
                {result.matches.excerpt.positions.length > 0 ? (
                  <span
                    innerHTML={getSnippet(
                      result.excerpt,
                      result.matches.excerpt.positions[0]
                    )}
                  />
                ) : (
                  result.excerpt
                )}
              </div>

              {/* Content matches with context */}
              <Show when={result.matches.content.positions.length > 0}>
                <div class="text-muted mt-2 space-y-1 bg-surface-secondary px-3 py-2 text-xs">
                  <For each={result.matches.content.positions}>
                    {(position) => (
                      <div innerHTML={getSnippet(result.content, position)} />
                    )}
                  </For>
                </div>
              </Show>
            </a>
          )}
        </For>
      </div>
    </Show>
  </Show>
</div>
💡
Protip

Use visual hierarchy to make search results scannable. Different text sizes, colors, and background shades help users quickly find what they’re looking for.

The Final Piece: Search Store

To manage the search state across components, I created a custom store using SolidJS’s context:

export const SearchProvider: Component<{ children: JSX.Element }> = (props) => {
  const [query, setQuery] = createSignal('')
  const [results, setResults] = createSignal<SearchResult[]>([])
  const [isLoading, setIsLoading] = createSignal(false)
  const [isInitialized, setIsInitialized] = createSignal(false)

  // Initialize search index
  createEffect(async () => {
    if (!isInitialized()) {
      const posts = await getCollection('blog')
      const searchDocs = blogToSearchableDocuments(posts)
      initializeSearch(searchDocs)
      setIsInitialized(true)
    }
  })

  // Handle real-time search
  createEffect(() => {
    const searchQuery = query().trim()

    if (searchQuery.length >= 3 && isInitialized()) {
      setIsLoading(true)
      try {
        const searchResults = search(searchQuery)
        setResults(searchResults)
      } finally {
        setIsLoading(false)
      }
    } else {
      setResults([])
    }
  })

  return (
    <SearchContext.Provider value={store}>
      {props.children}
    </SearchContext.Provider>
  )
}
💡
Protip

Keep your search store simple and focused. Use reactive primitives for state management and handle loading states explicitly.

Looking Back and Forward

What started as a “simple” feature turned into a deep dive into search implementation details I hadn’t even considered at the start. But through each challenge, the solution became clearer and more refined.

I’m already thinking about future improvements:

  • Adding search filters
  • Supporting advanced search syntax

But for now, I’m happy with how it turned out. The search is fast, the results are clean and relevant, and most importantly, it actually helps people find what they’re looking for.

The code for this implementation is available in my site’s repository. If you’re thinking about adding search to your static site, remember: sometimes the simplest solution is the best one. You might not need a powerful search server - a well-implemented client-side solution might be just what you’re looking for.