Skip to content
Pre-MVP. The 1.0 codebase is not a released product — see the 1.0 product boundary.

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:

Terminal window
npm install -D pagefind
npx pagefind --site dist

Pagefind 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.

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.

  • 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, display slug.
  • 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: locale is 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.

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.