Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Search for your site

The public read API serves published content. Its list routes are not a query engine, but it provides a live search endpoint, GET /v1/_search, and suggestions while a visitor types, GET /v1/_search/suggest. Managed and generated sites draw their search pages from it. Read the API from a server, build or Worker without CORS setup. A separately hosted browser app can read it after its origin is allowed under Settings → API → Browser origins; see browser origins. Never place drafts or private content in an unauthenticated public index.

Open Settings → Source & delivery → Site search. It needs site:infra. Tick Turn on site search and save.

Search always covers published pages and, on a Blog site, published posts. Under Also search, tick other public content types to add them, up to 16. A private content type is never offered and never searched.

By default a result whose title contains every word comes first, then the newest. To change that for one content type, open Matching and ranking under Site search and tick Custom ranking for that type.

  • Tick the fields the type is searched by, up to 16. A field left out is not searched for that type.
  • Give each field a weight from 1 to 10. A result ranks by the highest weight among its ticked fields that contain every word. 10 has the greatest weight.
  • New custom ranking settings start with the title at 10 and every other text field at 1.
  • A type you do not tune keeps the default ranking. Its title match counts as 10.
  • Untick the type to go back to the default.

A change applies to the next search. Nothing is rebuilt, and managed and generated sites rank the same way. A results page opened before the change starts a new search when the visitor requests more results.

Each searched content type has a Matching choice under Matching and ranking. Every word of a search must match.

Matching Finds Example
Whole words (default) The same word only “tour” finds “tour”, not “tours”
English word forms Other forms of an English word “tour” also finds “tours” and “touring”
Parts of words The letters anywhere inside a word “tour” also finds “tours” and “detour”
  • Words are letters, marks and digits. Marks are combining accents and the vowel and tone marks of scripts such as Thai, so they stay inside a word. Anything else only separates words: “don’t” is the words “don” and “t”.
  • English word forms works on every site. A result in English (en, en-GB, en_US and the like) matches word forms. A result in any other language matches whole words. Each result follows its own language, so a search in Thai that falls back to an English item matches the English item by word forms and the Thai items by whole words.
  • Languages written without spaces between words, such as Thai, match a whole run of letters under Whole words. Choose Parts of words for those.
  • A change applies to the next search. Every form is stored when content is saved, so nothing is rebuilt and old and new matching never mix in one answer. A results page opened before the change starts a new search when the visitor requests more results.

An item is also found by the names of the items it links to through a relation field, such as a post by its author’s name:

  • A linked item counts only while it is published, its content type is public and it is not in Trash. Its published name counts, in the language the visitor is served. A draft name never counts. A rename counts once it is published.
  • A private relation field, a private name field or a private content type adds nothing.
  • A result’s summary never shows linked names.

A site served by a managed renderer searches what is live now. Nothing is rebuilt:

  • A publication shows up in search at once. Unpublishing a language, moving an entry to Trash, deleting it and moving its address take effect at once too.
  • Results come in the visitor’s language. Under the fallback policy an item without that language is shown in the first available language in its fallback chain.
  • Drafts, private content types and items set to Hide from search engines never appear.
  • /search searches in the default language and /<language>/search in another. These pages are not indexed by search engines.
  • A Site search section on any page draws the same search box and shows results in place.

If search cannot answer, the page still loads and says “Search is not available right now. Try again later.” A failing search never blocks a release.

A published page whose address is /search or /<language>/search blocks a new release while search is on. Change that page’s address, or turn search off.

A generated site searches the same live index as a managed site, through the same endpoint and the same search box. Nothing is rebuilt to update search:

  • /search searches in the default language and /<language>/search in another. A generated site has these pages for the default language and for each language that published content or the Blog uses. A site language with nothing published gets no search page until a build includes published content in that language. The site navigation links to them. They render on each request, are never cached and are not indexed by search engines.
  • A Site search section turns its page into a page that renders on each request. Results show in place. Every search box on one page shares one search.
  • Publishing content, unpublishing a language, moving an item to Trash, deleting it or changing its address updates search at once, including for Blog posts. The Also search and Matching and ranking choices apply too.
  • A result links to its page at the address this build gave it, and a post to its Blog address. A page published after the last build, or another content type, shows without a link until the next build gives it an address.
  • Search off or an unknown language answers 404. If search cannot answer, the search page answers 503 and says “Search is not available right now. Try again later.” A search section on a page never fails the page.

Search reads /v1/_search on the site’s own host, so the site Worker needs global_fetch_strictly_public in apps/site/wrangler.jsonc. New sites have it. An existing file without it blocks site preparation and deployment by name.

The verified generated-site commands stay the same:

Terminal window
node scripts/install-dependencies.mjs apps/site
npm run --prefix apps/site check
npm run --prefix apps/site build

Do not run a bare npm install in the generated tree. Regeneration preserves shared dependency files. See extending your backend.

Every search box on a managed or generated site suggests matches while the visitor types:

  • Suggestions start at two characters and show up to five live items: title, short summary and link. Words match by the type’s Matching; the last word may be unfinished and matches the start of a word (anywhere inside one under Parts of words). An item whose title starts with the typed words comes first, then items whose title contains them, then the rest. Only items with an address on this site are offered. A tuned type is matched by its ticked fields only.
  • They read the same live content as search: drafts, private content types, Trash, unpublished languages and items hidden from search engines never appear. A publication or withdrawal shows up at once.
  • Use the arrow keys to move through the list. Enter opens the highlighted item; with nothing highlighted it runs the ordinary search. Escape closes the list, a second Escape clears the box. A click or tap opens an item. Screen readers hear the list as a combobox and hear how many suggestions there are.
  • If suggestions cannot load, the list stays hidden and the search box still works. Without JavaScript the box is the ordinary search form.
  • Nothing about what visitors type is stored or counted.

The box asks the site’s own /search?suggest=<words>&locale=<language>, which reads GET /v1/_search/suggest and links each item the way search results are linked. A managed site’s security policy allows exactly that one script, by hash.

A front end you build can call the same endpoint from its server:

Terminal window
curl "https://example.com/v1/_search?q=lighthouse&locale=en&limit=10"

Every word of q must match, according to each content type’s matching mode (see Choose how words match). Results whose title contains every word come first, then the newest, unless the site tunes ranking (see Tune ranking). Pass page.next_cursor back as cursor for the next page of the same query. A cursor from before a ranking or matching change is refused with public_search_invalid; start the search again. A result gives kind, model, slug and locale; build its link with your own routes. search_off means search is off for the site. The public read API lists every parameter and refusal.

For suggestions, send exactly one q (2 to 100 characters), one locale and optionally limit (1 to 10, default 5):

Terminal window
curl "https://example.com/v1/_search/suggest?q=light&locale=en"

Words match according to each content type’s matching mode, and the last word may be partial. The answer holds query, data, limit and locale; each item has the same fields as a search result.

Static search for a site you build yourself

Section titled “Static search for a site you build yourself”

For a generic static site, add Pagefind through that project’s normal dependency workflow and run its pinned executable after building HTML. The command added to your site’s build script is pagefind --site dist when dist really is its output directory. Publish the HTML and matching search files together. A failed build must not replace the last successful deployment.

External indexes: replace only after a complete crawl

Section titled “External indexes: replace only after a complete crawl”

A service such as Typesense or Meilisearch needs explicit withdrawal semantics. Repeated upserts alone never remove an item that was deleted, unpublished or moved out of the selected scope. Dee Wan does not own those clients or their atomic index operations.

Define the complete scope as a reviewed set of (site, model, locale) tuples. Stage a fresh empty generation for all those tuples, crawl every page to completion, validate every response, and write only to that staged generation. Key documents by site, model, stable item ID and actual locale, not by mutable slug. Skip fallback-locale results on another locale’s pass. Forward cursors unchanged. Missing, repeated or inconsistent cursors, invalid item shapes, HTTP errors, timeouts and scope failures abort the entire build.

Only after every scope finishes may the adapter atomically activate the complete generation, or perform an equivalently isolated full replacement. Readers must never observe partially reconciled deletes and inserts. An empty successful generation is valid and removes all previously indexed entries. If one scope fails, even after others finish, the old live generation remains unchanged. Abandoned stages can be cleaned up without touching the active one.

Capture the active generation’s revision when staging begins. Activation must compare that revision atomically or serialize rebuilds so a slower older crawl cannot overwrite a newer one. A lost activation acknowledgment is ambiguous: the adapter must resolve whether that generation is active, and discard must never delete active data. Multi-key writes or a pointer in eventually consistent storage alone do not prove this contract.

The dependency-free staged crawler exports rebuildSearch({ scopes, readPage, stage, maxPages, maxDocuments }). It performs no provider calls by itself. Supply these adapter operations:

Operation Required behavior
readPage(scope, cursor) Enforce a request timeout and HTTP success. Return the public API’s {data, page}. page has boolean has_more and nullable next_cursor.
stage.begin(scopes) Allocate an isolated empty generation and capture the live revision. Do not clear or write the live index.
stage.write(generation, batch) Write only into that generation. Reject failed or partial writes. Each item has a composite key, scope and original item. The adapter owns searchable field projection.
stage.activate(generation) Publish the complete generation atomically with stale-writer protection. Resolve uncertain acknowledgments by generation identity.
stage.discard(generation) Remove only inactive staged data. Never clear the current live generation, including after an ambiguous activation response.

The example bounds pages per scope and total accepted documents. It intentionally leaves provider credentials, index configuration and field extraction to your existing search adapter. The focused source checks cover successful withdrawal, empty replacement, failure after a completed scope, malformed cursors, staging errors and concurrent activation conflicts. Run node --test scripts/search-index.test.mjs from the CMS repository.

A completed paged crawl is not proof of a single-time snapshot while content is changing. For a strict cutover, coordinate a publishing pause or use an accepted published snapshot. Otherwise state the rebuild’s freshness limit and repeat it. Public API caching, when enabled, can delay observed withdrawal. Do not promise instant disappearance.

Before production use, demonstrate these behaviors. A withdrawn item disappears after a complete rebuild. A failed crawl leaves every old scope intact. A valid empty result clears stale records. An older concurrent build cannot become live after a newer one. Private-model tokens belong in server secrets and require a search service enforcing the same access. A webhook may trigger rebuilding, but is not itself a complete index. See the outbound contract.

Tell other systems when content is published with outbound webhooks.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes