Search
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 prop | Result |
|---|---|
| omitted | Hydrates as an island |
true | Hydrates as an island |
false | Static — 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
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
| Key | Behavior |
|---|---|
ArrowDown | Open the dropdown, or move the highlight to the next suggestion (wraps). |
ArrowUp | Move the highlight to the previous suggestion (wraps). |
Enter | Navigate to the highlighted suggestion's href. |
Escape | Close 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
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | /api/posts/search.json | URL of the SSG-generated JSON search index. |
placeholder | string | "Search..." | Placeholder text for the input. |
initialQuery | string | - | Initial query, e.g. seeded from a ?q= URL param on first render. |
debounceMs | number | 150 | Delay before a keystroke is applied as the active query. |
maxSuggestions | number | 8 | Maximum entries shown in the autocomplete dropdown. |
filterAttribute | string | - | When set, elements carrying this attribute (e.g. data-post-slug) are shown/hidden based on whether their value matches an entry key. |
emptyStateId | string | - | id of an element to reveal when the filtered list has zero matches. |
total | number | - | Result count shown before the index has loaded. |
itemLabel | string | "results" | Noun used in the result count, e.g. "articles". |
showCount | boolean | true | Show the "Showing X of N" result count row. |
action | string | - | No-JS fallback: submit ?q= to this path via a plain GET form. |
syncUrl | boolean | true | Mirror the active query into the address bar as ?q=. |
interactive | boolean | - | Overrides the hydration decision (see above). |