MenuChevron Down
Search - Docs - Artefact

Search

Forms
Auto-interactive

Introduction

An instant-search input over a pre-generated JSON index: keystrokes are debounced, matches surface as an autocomplete dropdown, and picking a result navigates to its href. It can optionally filter server-rendered elements in place (e.g. a list of blog cards) and always degrades to a plain ?q= GET form when JS is unavailable.

Search does not fetch on every keystroke against a live endpoint — it lazily loads one JSON document (src) on first interaction, then filters it entirely client-side. Build a matching index (see Search Index Format below) for whatever content you want searchable.

Hydration

Tier 1 — auto-interactive. Debounced filtering, the autocomplete dropdown, and keyboard navigation all require client JS, so Search hydrates by default. Pass interactive={false} to force the static fallback: a plain <input type="search" name="q">, wrapped in a GET <form> when action is set.

interactive propResult
omittedHydrates as an island
trueHydrates as an island
falseStatic — a plain GET form input, no client JS

All interactivity decisions in the library route through the shared shouldHydrate() helper in app/components/ui/island-utils.ts.

Usage

Search
import { Search } from "../components/ui";

export default function MyPage() {
  return (
    <Search
      src="/api/posts/search.json"
      placeholder="Search articles..."
      itemLabel="articles"
    />
  );
}

Search Index Format

src points to a static JSON document shaped like SearchIndexDocument (app/utils/search.ts):

interface SearchIndexEntry {
  /** Stable id (e.g. post slug) — matched against DOM filter attributes */
  key: string;
  /** Navigation target when the entry is picked from autocomplete */
  href: string;
  title: string;
  description?: string;
  tags?: string[];
  /** Precomputed lowercase text blob the query tokens are matched against */
  haystack: string;
}

interface SearchIndexDocument {
  generated: string;
  entries: SearchIndexEntry[];
}

buildHaystack() and tokenize() / filterEntries() in the same module are shared by the SSG index builder, the no-JS ?q= server fallback, and the client island, so all three agree on what counts as a match. /api/posts/search.json and /api/docs/search.json are the two indices already generated in this project — point src at either, or build your own the same way.

Filtering results in place

Set filterAttribute to also show/hide matching server-rendered elements as the query changes, instead of only offering an autocomplete dropdown. This is how the blog index page (app/routes/blog/index.tsx) pairs the search box with its already-rendered post cards:

<Search
  src="/api/posts/search.json"
  action="/blog"
  initialQuery={searchQuery}
  placeholder="Search articles..."
  itemLabel="articles"
  total={blogPosts.length}
  filterAttribute="data-post-slug"
  emptyStateId="blog-search-empty"
/>

{blogPosts.map((post) => (
  <article data-post-slug={post.slug}>...</article>
))}

<div id="blog-search-empty" hidden>
  No articles match your search.
</div>

Each element carrying data-post-slug is hidden unless its value matches an entry key in the current results; the element whose id matches emptyStateId is revealed once matches drop to zero. total seeds the result count shown before the index has finished loading.

No-JS fallback

When action is set, both the static and hydrated variants render a <form method="get"> around the input, named q. Without client JS this posts ?q= straight to action; a route reading that query param (e.g. via filterEntries() server-side) answers the same request the island would otherwise have handled — the blog page's /blog?q=... reads work whether or not JS ran.

Keyboard Interaction

KeyBehavior
ArrowDownOpen the dropdown, or move the highlight to the next suggestion (wraps).
ArrowUpMove the highlight to the previous suggestion (wraps).
EnterNavigate to the highlighted suggestion's href.
EscapeClose the dropdown if open; otherwise clear the query.

CMS Page Builder

This component is available as a search block in the Page Builder (content/pages/*.json):

{
  "type": "search",
  "src": "/api/posts/search.json",
  "placeholder": "Search posts...",
  "itemLabel": "posts",
  "maxSuggestions": 5,
  "debounceMs": 150
}

Props

PropTypeDefaultDescription
srcstring/api/posts/search.jsonURL of the SSG-generated JSON search index.
placeholderstring"Search..."Placeholder text for the input.
initialQuerystring-Initial query, e.g. seeded from a ?q= URL param on first render.
debounceMsnumber150Delay before a keystroke is applied as the active query.
maxSuggestionsnumber8Maximum entries shown in the autocomplete dropdown.
filterAttributestring-When set, elements carrying this attribute (e.g. data-post-slug) are shown/hidden based on whether their value matches an entry key.
emptyStateIdstring-id of an element to reveal when the filtered list has zero matches.
totalnumber-Result count shown before the index has loaded.
itemLabelstring"results"Noun used in the result count, e.g. "articles".
showCountbooleantrueShow the "Showing X of N" result count row.
actionstring-No-JS fallback: submit ?q= to this path via a plain GET form.
syncUrlbooleantrueMirror the active query into the address bar as ?q=.
interactiveboolean-Overrides the hydration decision (see above).