Skip to content

Content

Search

One search engine that runs in two places. Ship a static index to an on-device worker, or query it on the server, with a no-JS fallback page in both cases.

On this page

The site layer builds a search index from the section documents of your collections, meaning the prose split at each heading, so a hit lands on the exact section rather than only the page. You choose where the query runs: on the server, or on the visitor’s device against a static index. The search on this site works the second way, with a worker over a prerendered file, so a query costs no server round trip.

The static index is usually the better choice for a prerendered site, because it costs nothing to serve and works offline. Query on the server instead when the corpus is private, very large, or changes per request.

Two paths, one engine

// src/routes/docs/search.json/+server.ts — the prerendered index
import { docs } from '$lib/site.server';
export const prerender = true;
export const GET = docs.emit.search();
// src/lib/site.remote.ts — when the index shouldn't ship
import { remotes } from '@ogygia/content/server';
import { docs } from './site.server';
export const { search } = remotes(docs, { base: '/docs' });

Both paths use the same engine (docs.search), the same section documents and the same ranking; only the place where the query runs differs.

The palette

<Search> is the ⌘K palette, an island that loads on demand:

<script>
	import { Search } from '@ogygia/content';
</script>

<Search base="/docs" placeholder="Search the docs" />

The server renders only the trigger, which is a real link to the fallback page. On the first sign of intent (⌘K, / or a click) it loads the palette and starts an Orama worker that fetches and indexes the static file off the main thread. Ranking highlights matched terms and boosts title matches. No server ever sees a query, and nothing heavy loads for a visitor who never searches.

Orama is an optional peer dependency that the scaffold installs and that loads only on the search path:

npm i @orama/orama
pnpm add @orama/orama

The server path, rendered

If you query over the wire instead, the page takes a few lines and returns the same hits in the same shape:

<script>
	import { search } from '$lib/site.remote';
	let q = $state('');
</script>

<input bind:value={q} />
{#each await search(q) as hit}
	<a href={hit.href}>{hit.title} — {hit.excerpt}</a>
{/each}

The no-JS fallback

Search should work without JavaScript. The <Search> trigger is a real <a href="/docs/search">, which opens the palette when JS is available and otherwise follows the link to a plain results page that you render from a server load:

import { docs } from '$lib/site.server';
export const load = async ({ url }) => ({
	q: url.searchParams.get('q') ?? '',
	hits: await docs.search(url.searchParams.get('q') ?? '', { base: '/docs' })
});
<script>
	import { SearchPage } from '@ogygia/content';
	let { data } = $props();
</script>

<SearchPage q={data.q} hits={data.hits} />

The fallback uses the same index and ranking with no client JS at all. This site has one, which you can see by opening /docs/search directly.

Scoping and swapping

docs.search(q, { in: [collection] }) narrows the query to one collection, which is useful when a site carries several (docs and a blog, say) and a search box should cover only one. The static index includes every collection, and the worker filters by scope.

The engine itself is a contract rather than a fixed dependency. Orama is the default, and site({ search: { engine } }) replaces it with anything that can build and query the section documents, such as a hosted service, a different library or a test double.

What a hit looks like

SearchHit is plain data, ready to render:

fieldmeaning
hrefdeep link, baked for base; lands on the heading of the matched section
titlethe page title
sectionthe nav group / top-level section label
headingthe heading of the matched section
excerptthe matched prose, with <mark> around matched terms
scorethe rank from the engine, with title matches boosted

Do / don’t

  • Do ship the static index for a prerendered site. An on-device worker is faster to serve, works offline, and the server never sees a keystroke.
  • Do keep the fallback page. It is one server load, and it keeps search working when JS is off or has not loaded yet.
  • Don’t create a search remote and ship the index for the same search box. Pick one path: the index is for the worker, and the remote is for server queries.
  • Don’t build your own index from the raw bodies. docs.emit.search() splits by section, which is what lets a hit link straight to the right heading.