Skip to content

Content

Search

One search engine, two ways to run it. Ship a static index to an on-device worker, or query it on the server. A no-JS fallback page comes for free.

On this page

The site layer builds a search index from the section documents of your collections: the prose split by heading. Thus a hit lands on the exact section, not only the page. You pick where the query runs: on the server, or on the device of the visitor, against a static index. The search on this site is the second kind: a worker over a prerendered file. Thus a query costs no server round-trip.

When to use

Each docs site gets value from search. Prefer the static-index path for a prerendered site: it is free to serve, and operates offline. Use the server query 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' });

The same engine (docs.search), the same section documents, the same ranking. Only the location differs.

The palette

<Search> is the ⌘K palette. It is 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: a real link to the fallback page. On the first 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 marks matched terms, and boosts title matches. No server touches a query. Nothing heavy loads for a visitor who never searches.

Orama is an optional peer: installed by the scaffold, and loaded only on the search path:

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

The server path, rendered

When you query over the wire in its place, the page is a few lines. The same hits, 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 must operate without JavaScript. The <Search> trigger is a real <a href="/docs/search">. With JS, it opens the palette. Without JS, it 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 same index, the same ranking, zero client JS: the fallback below the palette. This site has one: open /docs/search directly.

Scoping and swapping

docs.search(q, { in: [collection] }) narrows the query to one collection. That is applicable when a site carries several (docs + blog), and a box must search only one. The static index carries each collection. The worker filters by scope.

The engine itself is a contract, not a dependency. The default is Orama. site({ search: { engine } }) swaps it for anything that can build and query the section documents: a hosted service, a different library, 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, and operates offline. The server never sees a keystroke.
  • Do keep the fallback page. It is one server load. With it, search survives when JS is off, or not yet loaded.
  • Don’t mint a search remote and ship the index for the same box. Pick one path. The index is for the worker. The remote is for server queries.
  • Don’t build your own index off the raw bodies. docs.emit.search() splits by section. Thus a hit deep-links to the correct heading.