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/oramapnpm add @orama/oramaThe 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:
| field | meaning |
|---|---|
href | deep link, baked for base; lands on the heading of the matched section |
title | the page title |
section | the nav group / top-level section label |
heading | the heading of the matched section |
excerpt | the matched prose, with <mark> around matched terms |
score | the 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
searchremote 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.