@foundrykit/search-plugin
v0.1.0
Published
A plugin for Payload CMS that provides a cached, grouped and ranked site search over the plugin-search index, with fuzzy matching, editor-controlled relevance and search analytics.
Maintainers
Readme
@foundrykit/search-plugin
Cached, grouped and ranked site search for Payload CMS, layered on top of
@payloadcms/plugin-search.
That plugin keeps a search collection in sync with your content through afterChange /
afterDelete hooks. Sync is the part of an index implementation that rots, so this plugin does not
reimplement it — it owns the read side:
- One index read per request, cached in memory, instead of one query per collection per keystroke
- Fuzzy matching via Fuse.js, so typos, plurals and word order stop mattering
- Ranking by field weight and collection weight, so a title hit beats an incidental description hit
- Grouping with per-group caps, and an opt-in empty-query preset
- Editor operability — reindex on demand, and a "what is searchable" report that gives reasons
- Search analytics — top queries and, more usefully, the ones returning nothing
Why the read side is the interesting half
A naive site search queries each source collection live with a like substring where clause.
That caps how good relevance can ever get: substring matching has no word boundaries (searching
tuni matches anything containing those four letters anywhere), there is no ranking, and there is
nothing to measure or rebuild. Every complaint arrives anecdotally and costs a developer a code
read to diagnose.
Install
pnpm add @foundrykit/search-pluginUsage
Install it after searchPlugin, since it reads the collection that one creates.
import { searchPlugin as payloadSearchPlugin } from '@payloadcms/plugin-search'
import { searchPlugin } from '@foundrykit/search-plugin'
export default buildConfig({
plugins: [
payloadSearchPlugin({
collections: ['pages', 'posts', 'hotels'],
}),
searchPlugin({
groups: [
{ slug: 'destinations', label: 'Destinations', limit: 3 },
{ slug: 'stays', label: 'Places to stay', limit: 4 },
// Utility pages are worth finding by name, but not worth suggesting unprompted.
{ slug: 'pages', label: 'Pages', limit: 3, showWhenEmpty: false },
],
collections: [
{ slug: 'regions', group: 'destinations', path: '/regions', weight: 3 },
{ slug: 'hotels', group: 'stays', path: '/hotels', icon: 'bed', weight: 2 },
{
slug: 'pages',
group: 'pages',
// Empty string, not undefined: these are served at the site root.
path: '',
excludeSlugs: ['home', 'gift-list'],
},
],
analytics: { retentionDays: 90 },
}),
],
})Endpoints
| Method | Path | Purpose |
| --- | --- | --- |
| GET | /api/site-search?q= | Grouped, ranked results. Empty q returns the preset. |
| POST | /api/site-search/reindex | Rebuild the projection now. Returns counts. |
| GET | /api/site-search/inspect | What is indexed, and why anything missing was skipped. |
| POST | /api/site-search/track | Attribute a click to a query. Only when analytics are on. |
Change the base with routePrefix. reindex and inspect require an authenticated user unless
you set requireAdminForOperations: false.
Options
| Option | Default | Notes |
| --- | --- | --- |
| collections | — | Required. Which collections are searchable, and how their results present. |
| groups | — | Required. Presentation buckets, with caps and empty-state opt-in. |
| cacheTTL | 60000 | Milliseconds to hold the projection. |
| fields | title 1, slug 0.4, description 0.2 | Fields matched, with relative weights. |
| fuzzy | { threshold: 0.35, distance: 100, minMatchCharLength: 3 } | Fuse tuning. |
| decodeEntities | true | Decode HTML entities in titles. |
| analytics | false | true, or { retentionDays, collectionSlug }. |
| requireAdminForOperations | true | Guard reindex and inspect. |
| routePrefix | /site-search | Base path for endpoints. |
| disabled | false | Bypass the plugin entirely. |
A note on minMatchCharLength
The default of 3 is deliberate. Two-character fragments match too much to be useful, and that is
the single most common source of "why is this unrelated thing in my results".
A note on the empty-query preset
presetResults orders by collection weight and then alphabetically. It is not popularity- or
analytics-ranked, and deliberately does not pretend to be — if you want a curated order, set
weights. Once analytics have accumulated, the top-queries report is the honest input for that
decision.
decodeEntities
Content migrated out of a CMS that stored HTML-encoded text (Umbraco, typically) keeps entities in
its titles. Rich-text renderers decode on output, so a page body reads correctly while every
plain-text surface — search results, filter labels, map pins — shows a literal &. Decoding at
projection time fixes all of them at once.
Client helpers
import { useSearchHotkey, useIsAppleDevice } from '@foundrykit/search-plugin/client'
useSearchHotkey(open) // Cmd+K / Ctrl+KThe hotkey is suppressed inside inputs, textareas, selects and contenteditable. That guard matters:
Ctrl+K is Lexical's "insert link" binding, and Lexical is Payload's own editor.
Analytics and privacy
Nothing identifying is stored — no IP, no session, no user relationship. Queries are behavioural
data, and aggregate counts plus the zero-result list are what actually inform relevance work.
Events past retentionDays are pruned on write, so the log stays bounded without needing a
scheduled job the host project may not have.
Dashboard
import { SearchDashboard } from '@foundrykit/search-plugin/rsc'A server component showing index counts, skipped documents with reasons, zero-result queries
and top queries. Wire it into admin.components.views or a custom dashboard.
Development
pnpm install
pnpm test:unit
pnpm build
pnpm lint