Client-Side Search Without a Backend
Build instant, typo-tolerant search for a content site — no search server, no API, no per-query cost. Everything runs in the browser over a small prebuilt index.
Client-Side Search Without a Backend
For a blog, a docs site, or a tool directory, you almost never need a search server. The whole corpus — titles, descriptions, tags — is small enough to ship to the browser and search there. The result is instant (no network round-trip per keystroke), free (no service to pay for), and works offline.
This guide builds it end to end: a tiny scoring function, a debounced React hook, and match highlighting. No dependencies.
When this approach works
Client-side search is the right call when:
- Your searchable data is bounded and modest — hundreds to low thousands of items, not millions.
- The data is already available at build time — MDX frontmatter, a registry file, a JSON manifest.
- You're searching metadata and short text, not full long-form article bodies (though you can include excerpts).
If you're searching gigabytes of content, or the corpus changes per user, you want a real search backend. For a personal site with a few hundred posts and tools, shipping a ~50KB index to the browser is completely fine and far simpler.
Step 1: Build the index
The "index" is just an array of searchable records. On a content site you assemble it from your existing sources:
// lib/search-index.ts
export interface SearchDoc {
title: string;
url: string;
description: string;
tags: string[];
type: "blog" | "guide" | "tool" | "project";
}
// In a real app this is generated from MDX frontmatter +
// your data/*.ts registries at build time.
export const searchIndex: SearchDoc[] = [
{
title: "Debouncing and Throttling in React",
url: "/guides/debounce-throttle-react",
description: "Tame events that fire too often with reusable hooks.",
tags: ["react", "performance"],
type: "guide",
},
// ...the rest, generated, not hand-written
];
Keep each record small. You're shipping this to every visitor, so include what you search and link to — not entire article bodies.
Step 2: Score, don't just filter
The naive version is index.filter(doc => doc.title.includes(query)). It works, but it's binary — a match in the title ranks the same as a match buried in a tag, and there's no ordering. A scoring function is barely more code and dramatically better UX.
The idea: award points for where and how the query matches, then sort by score.
function scoreDoc(doc: SearchDoc, query: string): number {
const q = query.toLowerCase().trim();
if (!q) return 0;
const title = doc.title.toLowerCase();
const desc = doc.description.toLowerCase();
const tags = doc.tags.join(" ").toLowerCase();
let score = 0;
// Exact title match — strongest signal
if (title === q) score += 100;
// Title starts with the query
else if (title.startsWith(q)) score += 60;
// Title contains the query somewhere
else if (title.includes(q)) score += 40;
// Tag hits are a strong intent signal
if (tags.includes(q)) score += 30;
// Description is the weakest match location
if (desc.includes(q)) score += 15;
// Per-word matching catches multi-word queries
// like "react performance" where no single field
// contains the whole phrase.
const words = q.split(/\s+/).filter(Boolean);
if (words.length > 1) {
for (const word of words) {
if (title.includes(word)) score += 8;
if (tags.includes(word)) score += 5;
if (desc.includes(word)) score += 2;
}
}
return score;
}
export function search(index: SearchDoc[], query: string): SearchDoc[] {
return index
.map((doc) => ({ doc, score: scoreDoc(doc, query) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.map((r) => r.doc);
}
The exact weights are taste, but the ranking they produce — title beats tag beats description, whole-phrase beats per-word — matches what users expect. Tune the numbers against your own content and watch where things land.
Step 3: Wire it into a debounced hook
You don't want to re-run search on every keystroke if the index is large, and you don't want results flickering. Debounce the query (see the debounce/throttle guide for the hook) and memoize the result:
import { useMemo, useState } from "react";
import { search, searchIndex } from "@/lib/search-index";
import { useDebouncedValue } from "@/lib/use-debounced-value";
export function useSearch() {
const [query, setQuery] = useState("");
const debounced = useDebouncedValue(query, 150);
const results = useMemo(
() => (debounced.trim() ? search(searchIndex, debounced) : []),
[debounced]
);
return { query, setQuery, results };
}
For a few hundred records the search itself runs in well under a millisecond, so a short 150ms debounce is plenty — it's there to avoid redundant renders, not because the search is slow.
Step 4: Highlight the matches
Showing why something matched makes results feel sharp. Split each title around the query and wrap the matched part:
function Highlight({ text, query }: { text: string; query: string }) {
const q = query.trim();
if (!q) return <>{text}</>;
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-purple-500/30 text-purple-200 rounded px-0.5">
{text.slice(idx, idx + q.length)}
</mark>
{text.slice(idx + q.length)}
</>
);
}
Note we slice by index, not by regex replacement — that preserves the original casing of the matched text and sidesteps having to escape regex metacharacters in the user's query (a ( or * in the query would otherwise throw).
Putting it together
function SearchBox() {
const { query, setQuery, results } = useSearch();
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
autoFocus
/>
<ul>
{results.map((doc) => (
<li key={doc.url}>
<a href={doc.url}>
<Highlight text={doc.title} query={query} />
<span className="badge">{doc.type}</span>
</a>
</li>
))}
</ul>
{query && results.length === 0 && <p>No matches for "{query}".</p>}
</div>
);
}
That's a complete, fast, free search experience — no backend, no third-party service, no per-query cost.
Where to go next
A few upgrades, in rough order of value:
- Typo tolerance. Add a Levenshtein-distance fallback so "javscript" still finds "JavaScript." Only run it when an exact pass returns nothing, so the common case stays fast.
- Keyboard navigation. Arrow keys to move through results, Enter to open. Cheap to add and makes it feel native.
- Lazy-load the index. If it's large, fetch the JSON on first focus of the search box rather than shipping it in the initial bundle.
But none of that is required to ship. The scoring function plus the debounced hook is a genuinely good search experience for any small-to-medium content site — and you built it without standing up a single piece of infrastructure.
Stay in the flow
Get vibecoding tips, new tool announcements, and guides delivered to your inbox.
No spam, unsubscribe anytime.