Skip to content

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.

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.

The index tracks writes automatically — there is no background job:

  • create, update, publish, and upsertTranslation re-index the document.
  • delete, deleteMany, unpublish, and schedule remove 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.

Terminal window
pnpm cms:reindex

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

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.

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.

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.

src/pages/api/search.ts
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 });
};

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.