Search
Kide maintains a full-text search index in SQLite using an FTS5 virtual table (cms_search_index) with a trigram tokenizer. The index covers published documents in collections that opt in with searchable, one row per supported locale. Query it with the search() function from @kidecms/core — typically to power a search page or endpoint on the public site.
This is separate from the two admin-facing searches: the admin command palette and the local API’s search find-option both use a simple substring match, not the FTS index. See Substring search in the local API.
Making a collection searchable
Section titled “Making a collection searchable”import { defineCollection, fields } from "@kidecms/core";
export default defineCollection({ slug: "posts", labels: { singular: "Post", plural: "Posts" }, searchable: true, fields: { ... },});| Value | Behavior |
|---|---|
unset / false |
Collection is never indexed |
true |
Index all fields of type text, slug, richText, content, blocks, or array |
{ fields: [...] } |
Index only the named fields |
Each indexed row stores a title (the collection’s labelField value), a body (the searchable fields flattened to plain text — rich text and blocks are stripped to their text content), a public URL built from pathPrefix and slug, and the publish date.
Only published documents are indexed. For draft-enabled collections, drafts and scheduled documents are excluded until published. For translated collections, each supported locale gets its own row with translatable fields overlaid from that locale’s translation.
How the index is maintained
Section titled “How the index is maintained”The index tracks writes automatically — there is no background job:
create,update,publish, andupsertTranslationre-index the document.delete,deleteMany,unpublish, andscheduleremove it from the index.- Scheduled publishing goes through
publish/unpublish, so it stays in sync too.
Indexing is fire-and-forget: failures are logged but never break the write.
Rebuilding from scratch
Section titled “Rebuilding from scratch”pnpm cms:reindexClears the index and re-indexes every document in every searchable collection. Safe to run at any time. Run it after adding searchable to a collection with existing content, or after changing which fields are searchable — those changes only affect documents on their next write otherwise.
Bulk imports
Section titled “Bulk imports”Per-document indexing adds overhead you don’t want in a large import. Pass _skipSearch: true to writes and rebuild once at the end with the reindex() helper from createCmsContext():
import { createCmsContext } from "@kidecms/core/context";
const { cms, reindex, dispose } = await createCmsContext();
for (const item of data) { await cms.posts.create(item, { _system: true, _skipSearch: true });}
await reindex();await dispose();See Migrations for the full bulk-import workflow.
Querying
Section titled “Querying”import { search } from "@kidecms/core";
const results = await search("jääkiekko", { locale: "fi", limit: 10 });| Option | Type | Default | Description |
|---|---|---|---|
locale |
string |
all locales | Restrict to one locale’s rows |
collections |
string[] |
all | Restrict to collection slugs |
docIds |
string[] |
all | Restrict to specific documents |
sort |
"relevance" | "title" | "date" |
"relevance" |
Result order |
limit |
number |
20 (max 200) |
Max results |
Each result:
{ collection: string; docId: string; locale: string | null; title: string; url: string; // public URL, e.g. "/fi/blog/hello" snippet: string; // HTML-escaped excerpt, matches wrapped in <mark>…</mark> publishedAt: string | null; rank: number; // bm25 score, lower is better}snippet is safe to render directly — the content is HTML-escaped and only the <mark> tags around matched terms are markup.
Matching behavior
Section titled “Matching behavior”The trigram tokenizer matches substrings inside words, which handles compound words, and tolerates typos: candidate rows come from FTS5, then a fuzzy edit-distance pass drops false positives and (in relevance mode) puts the closest matches first. Query terms shorter than three characters are ignored — the trigram tokenizer cannot index them.
Example endpoint
Section titled “Example endpoint”import type { APIRoute } from "astro";import { search } from "@kidecms/core";
export const GET: APIRoute = async ({ url }) => { const q = url.searchParams.get("q") ?? ""; const results = await search(q, { collections: ["posts", "pages"] }); return Response.json({ results });};Substring search in the local API
Section titled “Substring search in the local API”The search option on find and count is a different, simpler mechanism: a case-insensitive substring match (SQL LIKE) across all text, slug, email, and select fields of the collection. It does not use the FTS index, works on any collection regardless of searchable, and respects the usual status and access filtering — which is why the admin UI’s global search uses it. No relevance ranking or snippets.
import { cms } from "@/cms/.generated/api";
const posts = await cms.posts.find({ search: "hello", status: "any" });See Local API.