Search & Discovery
Enable site-wide full-text search across plugin content with Postgres or Elasticsearch ranking engines.
VitNode includes a unified site-wide search and discovery engine. Searchable records across all plugins are projected into the core_search_index table, powering /search and /discover.
This guide covers site-wide search. For search boxes on individual tables, see Search.
Quick start
Index when a record changes
Index or update an item from any Hono route handler via c.get("search"):
await c.get('search').index({
itemType: 'article',
itemId: article.id,
title: article.title,
content: article.content, // HTML automatically stripped to plain text
url: `/articles/${article.slug}`,
authorIds: [article.authorId],
isPublic: article.status === 'published',
createdAt: article.createdAt,
})Delete removed records
When an item is deleted, remove it from the index:
await c.get('search').delete('article', article.id)Register a rebuild indexer
To allow admins to re-index all historical content from the AdminCP, give your plugin a search indexer. It rebuilds one item type page by page: load returns the documents for up to limit source rows starting at offset, plus how many rows it read (0 ends the rebuild). count is optional and powers the coverage report.
A Content Engine type with search enabled gets its indexer automatically.
Write one only for data of your own.
import type { SearchIndexer } from '@vitnode/core/api/models/search'
import { asc } from 'drizzle-orm'
import { recipes } from '@/database/recipes'
export const recipeSearchIndexer: SearchIndexer = {
itemType: 'recipe',
count: async (c) => await c.get('db').$count(recipes),
load: async (c, offset, limit) => {
const rows = await c
.get('db')
.select()
.from(recipes)
.orderBy(asc(recipes.id))
.limit(limit)
.offset(offset)
return {
documents: rows.map((recipe) => ({
itemType: 'recipe',
itemId: recipe.id,
title: recipe.title,
content: recipe.description,
url: `/recipes/${recipe.slug}`,
authorIds: [recipe.authorId],
createdAt: recipe.createdAt,
})),
itemsRead: rows.length,
}
},
}Register it in config.api.ts (a module's searchIndexers works too):
export const recipesApiPlugin = () =>
buildApiPlugin({
manifest,
searchIndexers: [recipeSearchIndexer],
})Pluggable Search Engines
VitNode supports two search engines:
- PostgreSQL Full-Text Search (Default):
- Uses native
tsvector+ GIN indexes with weighted ranking (titleweighted abovecontent). - Zero additional infrastructure required.
- Uses native
- Elasticsearch (
@vitnode/elasticsearch):- Offloads indexing and search to an Elasticsearch or OpenSearch cluster.
- Unlocks fuzzy matching, phrase boosts, and decay scoring.
For setup, credentials, configuration, and the first rebuild, follow the Elasticsearch tutorial.
Filter the search page
/search has a filter sidebar next to the results (a Filters sheet on phones):
- Content - tick one or more types. Each row shows how many results it would have with the other filters applied.
- Date created - any time, the past 24 hours, week, month or year, or a Custom range picked on a calendar.
- Authors - add as many people as you like. A result matches when any of them wrote it, which counts co-authors too.
Results sit on a timeline grouped by month and day, so you can tell at a glance what happened yesterday and what is from last spring.
The same filters work in the public search API:
| Parameter | Example | What it does |
|---|---|---|
types | forum.topic,forum.comment | Only these content types |
authorIds | 1,7 | Written by any of these members (up to 20) |
from | 2026-10-01T00:00:00.000Z | Created at or after this moment |
to | 2026-10-14T23:59:59.999Z | Created at or before this moment |
curl "http://localhost:3000/api/@vitnode/core/search?authorIds=1,7&from=2026-10-01T00:00:00.000Z"Two small routes feed the sidebar:
GET /api/@vitnode/core/search/authors?search=axfinds members credited on public content. Withoutsearchit returns the most active ones.GET /api/@vitnode/core/search/facetstakes the same filters as the search route (minustypes) and returns{ "types": { "forum.comment": 4, ... } }.
A search engine plugin that can count implements countByType and sets capabilities.facets to true. One that can't returns types: null, and the sidebar quietly drops the numbers. Engines should also honour authorIds, or the author filter will be more of a suggestion.
Engines that can remove many documents at once can implement deleteMany(c, itemType, itemIds). Deleting a container (say, a forum with all its topics) then costs one call instead of one per child. Skip it and VitNode politely calls delete for each item.
Browsing without a search term (a timeline, /discover, or the API without search) returns the first 1,000 characters of each hit's content - plenty for the snippet under the title. Search with a term and you get the whole text, so the snippet can jump to the match.
Member timelines
Every member gets a timeline: everything credited to them, newest first. It lives on their profile (/users/{nameCode}) and on the Timeline tab of their AdminCP user page.
Who sees what depends on who's looking:
| Viewer | Published | Hidden | Drafts |
|---|---|---|---|
Anyone, on /search and /discover | Yes | Only its authors and editors, with an eye-off icon | No |
| Anyone, on a member's profile | Yes | Only its authors and editors, with an eye-off icon | No |
| The member, on their own profile | Yes | Yes, with an eye-off icon | Yes, marked Draft |
| Staff, on the AdminCP user page | Yes | Yes, with an eye-off icon | Yes, marked Draft |
The Hidden column follows Who can still see it. An editor is staff with can_edit on the content type.
A draft has no public page yet, so its title isn't a link. Nobody gets sent to a 404.
Content Engine types opt in with search.authorField, which names the field.user() that holds the authors. With a multi-author field, every co-author gets the record on their timeline:
export const blogPostContentType = defineContentType({
fields: {
authorId: field.user({ min: 1, multiple: true, ordered: true }),
// ...
},
search: {
titleField: 'title',
contentFields: ['title', 'content'],
pathTemplate: '/blog/article/{slug}',
authorField: 'authorId',
},
})Every author is shown on the result, in the field's order, on /search, /discover and timelines alike. It shows overlapping avatars, then the names, such as admin, devloop_tester and aXen. Past three authors the list shortens to admin, devloop_tester and 2 others. Each hit in the API carries authorIds and authors (id, name, nameCode, avatar) in that order, and author stays the first one for code that only needs a single name. After turning authorField on for content that already exists, rebuild the index so older records get their authors.
The timelines come from two routes:
GET /api/@vitnode/core/search/timeline/{userId}is public. It includes drafts only when the signed-in viewer is that member.GET /api/@vitnode/core/admin/users/{id}/timelineneeds theusers→can_viewstaff permission and returns every state.
Result URLs for Content Engine types
search.pathTemplate is the English route of a result's page, with one {slug} placeholder. To turn search on for a content type, see Content Engine search. Leave the language out: VitNode adds the prefix, the domain and any translated segments from your app's localized URL settings.
pathTemplate | English result | Polish result (as-needed) | Polish result (with domains) |
|---|---|---|---|
/blog/article/{slug} | /blog/article/hello-world | /pl/blog/article/witaj-swiecie | https://vitnode.pl/blog/article/witaj-swiecie |
With '/blog/article/:slug': '/wpisy/:slug' in routePaths.pl, the Polish result becomes /pl/wpisy/witaj-swiecie.
Once domains is configured, every result URL is absolute, so a click always opens the right domain.
The rules:
- It starts with
/and holds exactly one{slug}. No other placeholder, empty segment,..or whitespace. - A page route has to serve it (
{slug}read as:slug). If a plugin exports its content types fromsrc/content.tsand none does,devandbuildstop withcontent-url-without-page. See Content URLs need a page. {locale}is no longer needed. A legacy leading/{locale}/(like/{locale}/blog/article/{slug}) is still accepted and stripped.{locale}anywhere else fails with:search.pathTemplate "..." places "{locale}" somewhere other than the start of the path. The language is no longer part of the template: i18n.localePrefix, i18n.domains and i18n.routePaths now place it. Remove "{locale}" (recommended), or keep it only as the first segment ("/{locale}/...").- A content type that isn't localized can't use
{locale}at all.
Result URLs are stored when a record is indexed. After you change
pathTemplate, routePaths, domains or localePrefix, rebuild the index
below so older results stop pointing at the old addresses.
A document you index yourself with c.get('search').index() keeps its url exactly as you pass it, so pass the public URL for that document's language.
Manage the search index in the Debug Panel
Open Debug Panel → Search in the AdminCP (/admin/core/debug/search), or type "Search index" into the ⌘K admin search:
- The stat cards show the index status, the engine, how many items are indexed and when the last one was indexed. Recent sync errors appear below them.
- The collections table lists indexed counts per collection, with a search box to find one.
- Reindex rebuilds one collection, and Rebuild index in the header rebuilds all of them.
- Remove documents shows up instead of Reindex on a collection with no rebuild indexer. It clears that collection's documents, and live plugin writes may add them back.
Opening the section needs system → can_view. The rebuild, reindex and remove buttons need system → can_manage_search.
A rebuild is a queued job, so it only runs once cron is running. Until then it waits in the queue, patient but unproductive.
Search Document Reference
Prop
Type