Content
Search
One search engine, two ways to run it. Ship a static index to an on-device worker for zero-JS-cost queries, 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 your collections’ section documents: the prose split by heading, so a hit lands on the exact section, not just 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 is the second kind: a worker over a prerendered file, so a query costs no server round-trip.
When to use
Any docs site benefits from search. Prefer the static-index path for a prerendered site: it is free to serve and works offline. Reach for the server query when the corpus is private, huge, 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' });Same engine (docs.search), same section documents, 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 just the trigger: a real link to the fallback page. On first intent (⌘K, /, or a click) it loads the palette and spins up 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 only ever loaded on the search path:
npm i @orama/oramapnpm add @orama/oramaThe server path, rendered
When you query over the wire instead, the page is a few lines. Same hits, 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 work 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 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} />Same index, same ranking, zero client JS: the fallback beneath the palette. This site has one: open /docs/search directly.
Scoping and swapping
docs.search(q, { in: [collection] }) narrows the query to one collection. Useful when a site carries several (docs + blog) and a box should search just one. The static index carries every collection; the worker filters by scope.
The engine itself is a contract, not a dependency: the default is Orama, and 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, landing on the matched section’s heading |
title | the page title |
section | the nav group / top-level section label |
heading | the matched section’s heading |
excerpt | the matched prose, with <mark> around matched terms |
score | the engine’s rank, title matches boosted |
Do / don’t
- Do ship the static index for a prerendered site. An on-device worker is faster to serve and works offline; the server never sees a keystroke.
- Do keep the fallback page. It is one server load, and it means search survives with JS disabled or still loading.
- 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 so a hit deep-links to the right heading.