Search for your site
The public read API is deliberately not a query engine (SPEC.md §7). It serves
published snapshots by model, slug, locale, relation and tag — there is no
?q=. That is a design decision, not a gap: a LIKE scan over D1 snapshots
would be slow, unranked, and the wrong shape for the one row-per-document
model.
Search belongs to the consumer side. Three patterns work today, in order of effort.
1. Pagefind — static sites, zero infrastructure
Section titled “1. Pagefind — static sites, zero infrastructure”If your site builds statically (or the generated API build emits HTML), index the built output:
npm install -D pagefindnpx pagefind --site distPagefind indexes the rendered pages, serves a small WASM UI from the same bucket, and needs no server. Re-run it in the same CI job that builds the site. This is the right default for brochure-style sites.
2. Typesense / Meilisearch — app-like search
Section titled “2. Typesense / Meilisearch — app-like search”Crawl the public API into a search service, then query that from your frontend.
// One page per model per locale, following the cursor. An item is already// projected into the requested locale, so index one locale per pass.const base = 'https://content.example.com/v1';let cursor: string | undefined;do { const url = new URL(`${base}/article`); url.searchParams.set('locale', 'en'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url); const { data, page } = await res.json(); for (const item of data) { await index.upsert({ id: `${item.id}:${item.locale}`, title: item.fields.title ?? '', body: item.fields.body ?? '', slug: item.slug, locale: item.locale, published_at: item.published_at, }); } // Pagination lives in `page`, beside `data`. The cursor is opaque: send back // exactly what you were given. cursor = page.has_more ? page.next_cursor : undefined;} while (cursor);Re-index on a schedule, or on the deploy webhook your CI already receives. Key
the index on the item id (plus its locale) — slugs can change, ids do not.
3. A tiny search Worker — no external service
Section titled “3. A tiny search Worker — no external service”For small sites, one Worker that holds the index in memory/KV and refreshes on a cron is enough:
// search-worker: GET /search?q=recipe// Refresh: cron pulls every public model once per hour.export default { async fetch(req, env): Promise<Response> { const q = new URL(req.url).searchParams.get('q')?.toLowerCase() ?? ''; if (!q) return Response.json({ results: [] }); const index = JSON.parse(await env.SEARCH_INDEX.get('all') ?? '[]'); const results = index .filter((d) => (d.title + ' ' + d.body).toLowerCase().includes(q)) .slice(0, 20); return Response.json({ results }); },
async scheduled(_event, env, _ctx) { const res = await fetch(`${env.PUBLIC_API}/article?locale=en&limit=100`); const { data } = await res.json(); await env.SEARCH_INDEX.put('all', JSON.stringify( data.map((d) => ({ title: d.fields.title, body: strip(d.fields.body), slug: d.slug })) )); },};This is substring matching, not ranking — fine under a few thousand documents, wrong above that. Move to Typesense when it hurts.
Rules that hold for all three
Section titled “Rules that hold for all three”- Index published snapshots only, through the public API. The admin API never feeds a search index; drafts and unpublished content must not leak.
- Key documents on the item
id, displayslug. - Re-index after deploys, not after every publish — the public API is cache-tagged, so a stale index for minutes is normal and harmless.
- Respect locales:
localeis a request parameter and an item comes back already projected into one locale, so crawl once per locale and keep one index document per locale, or a locale facet — never one merged blob.
What would move search into the product
Section titled “What would move search into the product”The graduation build already pulls site data to generate the typed API. Emitting a static search index as another build artifact fits the thesis: the index becomes a file the customer owns, refreshed by the same CI that rebuilds their API. That is the point at which “search” becomes a Dee Wan feature rather than a consumer recipe.