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, register a search indexer in your plugin's config.api.ts:
import { buildSearchIndexer } from '@vitnode/core/api/lib/search'
import { blog_posts } from '@/database/posts'
export const postSearchIndexer = buildSearchIndexer({
itemType: 'article',
totalCount: async (c) => {
return await c.get('db').$count(blog_posts)
},
batch: async (c, { limit, offset }) => {
const posts = await c
.get('db')
.select()
.from(blog_posts)
.limit(limit)
.offset(offset)
return posts.map((post) => ({
itemType: 'article',
itemId: post.id,
title: post.title,
content: post.content,
url: `/articles/${post.slug}`,
authorIds: [post.authorId],
createdAt: post.createdAt,
}))
},
})Register it in config.api.ts:
export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: 'blog',
searchIndexers: [postSearchIndexer],
})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.
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/{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/{slug} | /blog/hello-world | /pl/blog/witaj-swiecie | https://vitnode.pl/blog/witaj-swiecie |
With '/blog/: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/{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.
AdminCP Search Management
Manage search status at Core → Advanced → Search (/admin/core/advanced/search):
- View indexed record counts across all collections.
- Trigger background rebuilds of specific collections or the entire site.
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