Site Search for a Personal Website — Helping Readers Find the Page They Remember
Some readers arrive through a search engine, read one page, and leave. Others remember a sentence months later and return, only to discover that they cannot recall the title or URL. A personal website may be small, but once it has years of notes, tutorials, and essays, finding one remembered page can feel like looking for a particular screwdriver in an unlabelled drawer.
Site search gives those readers a direct route back into the archive. It does not need to imitate Google or introduce a large hosted service. For many personal sites, a small, understandable search index is enough. This guide explains the choices, builds a client-side implementation, and covers the details that turn a text box into a useful part of the website.
Search is navigation for imperfect memory
Menus work when visitors understand our categories. Archive pages work when they remember a date. Tags work when author and reader use the same vocabulary. Search handles the messier situation: someone remembers “that article about an old link” but not that its title contains “link rot.” It lets the visitor describe the destination in their own words.
This is why search should complement navigation rather than replace it. A clear menu still teaches newcomers what the site contains, while search helps returning readers retrieve something specific. Think of a kitchen: labels on the cabinets provide structure, but being able to ask where the coffee filters are is useful when the structure is not in your head.
Choose an approach that matches the archive
There are three common approaches. A server-side search queries a database whenever someone submits a term. It scales well and can rank full content accurately, but it needs backend code, input validation, rate limiting, and maintenance. A hosted service can provide typo tolerance and sophisticated ranking, but it adds an external dependency and may send visitor queries to another company.
A client-side search downloads a compact index and filters it in the browser. It is an excellent fit for a static or modest personal website: no search server is required, queries remain on the reader's device, and the feature can keep working from a cached copy. Its main limit is index size. If the JSON grows to several megabytes or contains thousands of long documents, downloading and parsing it on every visit becomes wasteful.
Start with titles, summaries, URLs, and perhaps tags. That small index often produces better results than indexing every word because it emphasizes what each page is actually about. Full-text search can be introduced later when real usage shows that metadata is insufficient.
Build a small, explicit search index
The index can be generated during the normal site build or publishing process. Do not maintain it by hand if articles already live in a CMS; manual copies eventually drift. Export only published, public pages, remove HTML from summaries, and use canonical URLs. A simple file might look like this:
[
{
"title": "Preventing Link Rot on a Personal Website",
"url": "/preventing-link-rot-personal-website/",
"summary": "Practical ways to preserve references and repair old links.",
"tags": ["web", "maintenance"]
},
{
"title": "A Useful 404 Page for a Personal Website",
"url": "/useful-404-page-personal-website/",
"summary": "Turn a dead end into a helpful route back into the site.",
"tags": ["web", "navigation"]
}
]
Keep the schema boring. Stable fields are easier to generate, cache, and consume than a clever structure optimized too early. Also exclude private drafts and sensitive metadata. A search index is a public document, even if no visible page links directly to it.
Create an accessible search form
The interface should still make sense before JavaScript runs. Use a real <form>, a visible label, and a search input. If a server endpoint exists, the form can submit there as a fallback; otherwise, explain gracefully that interactive search needs JavaScript rather than presenting a button that silently does nothing.
<form id="site-search" role="search">
<label for="search-query">Search this site</label>
<input id="search-query" name="q" type="search"
autocomplete="off" minlength="2">
<button type="submit">Search</button>
</form>
<p id="search-status" aria-live="polite"></p>
<ol id="search-results"></ol>
The label cannot be replaced by placeholder text: placeholders disappear when typing and frequently have poor contrast. The live status announces result counts to screen-reader users without moving keyboard focus unexpectedly. An ordered list gives the output a meaningful structure.
Filter and rank without making it mysterious
For a small archive, transparent ranking beats a complicated algorithm. Normalize case, split the query into terms, require every term to appear somewhere, then give title matches more weight than summary or tag matches. The following implementation fetches the index only after a valid search and renders results without injecting index text as HTML:
const form = document.querySelector('#site-search');
const input = document.querySelector('#search-query');
const status = document.querySelector('#search-status');
const results = document.querySelector('#search-results');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const terms = input.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
if (terms.join('').length < 2) return;
const response = await fetch('/search-index.json');
if (!response.ok) throw new Error('Search index unavailable');
const pages = await response.json();
const matches = pages.map((page) => {
const title = page.title.toLowerCase();
const details = `${page.summary} ${page.tags.join(' ')}`.toLowerCase();
if (!terms.every((term) => title.includes(term) || details.includes(term))) return null;
const score = terms.reduce((sum, term) => sum +
(title.includes(term) ? 10 : 0) + (details.includes(term) ? 1 : 0), 0);
return { page, score };
}).filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 20);
results.replaceChildren(...matches.map(({ page }) => {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = page.url;
link.textContent = page.title;
const summary = document.createElement('p');
summary.textContent = page.summary;
item.append(link, summary);
return item;
}));
status.textContent = `${matches.length} result(s) found`;
});
Using textContent matters. Even when the index is generated from our own CMS, treating strings as text avoids turning an accidental HTML fragment into markup. Add a try...catch in production so a network failure becomes a readable message rather than an unhandled error.
Make empty states genuinely helpful
“No results” is technically correct but not very useful. Suggest removing a word, checking spelling, or browsing the archive. Preserve the query in the URL with ?q=... so results can be bookmarked and the Back button behaves naturally. When there are matches, show the count and enough context to distinguish pages with similar titles.
Avoid highlighting matches by constructing raw HTML. If highlighting is important, split text into nodes and wrap matching segments safely. Also avoid starting a request on every keystroke unless the input is debounced. A submit-first design is calmer, reduces work on low-powered phones, and remains easy to understand.
Performance, privacy, and multilingual content
Serve the index compressed and with sensible caching headers. Give it a new revision or update its validator whenever content changes. Load it on demand instead of adding it to every page's initial payload. Measure the compressed transfer size; that number is more useful than guessing whether an index is “small.”
Client-side search keeps the phrase itself local, but ordinary server logs may still record a query if it is placed in the URL. Decide whether that is acceptable, and never send search terms to analytics by default. Search boxes often contain surprisingly personal fragments.
For a multilingual site, either publish one index per locale, such as /id/search-index.json, or include a locale field and filter before ranking. Do not mix all languages indiscriminately. Readers searching in Indonesian should not have useful results buried under English and German pages, and language-specific tokenization may eventually require different rules.
Test the paths people actually take
Create a short test list from real article vocabulary. Try exact titles, a phrase found only in a summary, different capitalization, extra spaces, a misspelling, an empty query, and a term with no matches. Test keyboard-only operation and a narrow mobile viewport. Disconnect the network after the page loads to see whether failure is understandable.
Search quality is not merely the number of matches. The desired page should appear near the top, stale or private pages should never appear, and a newly published article should enter the index promptly. Periodically check index URLs for redirects and 404 responses; search should not become another map pointing toward closed roads.
A small search feature can stay small
A useful personal-site search does not need a cluster, an opaque ranking model, or surveillance. It needs a clean index, an accessible form, predictable matching, helpful empty states, and a publishing process that keeps everything current. Begin with metadata and client-side filtering, then add complexity only when the archive and its readers demonstrate a real need.
If you run a personal website, try searching for one article you vaguely remember rather than one whose title you already know. That small experiment reveals whether your navigation supports human memory. Share what worked, what failed, or how you built your own search in the comments; another site owner may be looking for exactly that missing piece.
