Logo VitNode

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.

Single table filtering

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:

plugins/blog/src/api/indexers/post.indexer.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:

plugins/blog/src/config.api.ts
export const blogApiPlugin = () =>
  buildApiPlugin({
    pluginId: 'blog',
    searchIndexers: [postSearchIndexer], 
  })

Pluggable Search Engines

VitNode supports two search engines:

  1. PostgreSQL Full-Text Search (Default):
    • Uses native tsvector + GIN indexes with weighted ranking (title weighted above content).
    • Zero additional infrastructure required.
  2. 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:

ViewerPublishedHiddenDrafts
Anyone, on /search and /discoverYesOnly its authors and editors, with an eye-off iconNo
Anyone, on a member's profileYesOnly its authors and editors, with an eye-off iconNo
The member, on their own profileYesYes, with an eye-off iconYes, marked Draft
Staff, on the AdminCP user pageYesYes, with an eye-off iconYes, 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:

plugins/blog/src/content/post.ts
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}/timeline needs the users → can_view staff 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.

pathTemplateEnglish resultPolish result (as-needed)Polish result (with domains)
/blog/{slug}/blog/hello-world/pl/blog/witaj-swieciehttps://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 from src/content.ts and none does, dev and build stop with content-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.
Rebuild after changing URLs

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

Learn More