Vite Search Plugin Advanced Topics

The Vite Search Plugin is split into small pieces: Markdown discovery, normalized records, adapter output, and a virtual module. Projects can use the default behavior or build their own pipeline on top of the same records.

Type Information

import type { Plugin, ResolvedConfig } from 'vite'

type MaybePromise<T> = T | Promise<T>

type JsonPrimitive = string | number | boolean | null
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }

type SearchRecordType = 'page' | 'heading' | 'content'

interface SearchRecordSource {
  file: string
  relativeFile: string
}

interface SearchRecord {
  id: string
  type: SearchRecordType
  title: string
  content: string
  url: string
  path: string
  anchor?: string
  section?: string
  hierarchy: string[]
  tags: string[]
  source: SearchRecordSource
  meta?: Record<string, JsonValue>
}

interface SearchIndex {
  generatedAt: string
  recordCount: number
  records: SearchRecord[]
}

interface SearchFrontmatterOptions {
  titleFields?: string[]
  descriptionFields?: string[]
  tagFields?: string[]
  searchField?: string
  metaFields?: string[]
}

interface MarkdownSearchOptions {
  root: string
  include?: string | string[]
  exclude?: string | string[]
  landingPage?: string
  routeBase?: string
  frontmatter?: SearchFrontmatterOptions
  headingRecords?: boolean
  contentRecords?: boolean
  minContentLength?: number
}

interface CreateSearchIndexOptions {
  markdown?: MarkdownSearchOptions | MarkdownSearchOptions[]
  base?: string
}

interface SearchAdapterOutput {
  fileName: string
  source: string | Uint8Array
}

interface SearchAdapterContext {
  config?: ResolvedConfig
  index: SearchIndex
  options: ViteSearchPluginOptions
}

interface SearchAdapter {
  name: string
  transform: (
    records: SearchRecord[],
    context: SearchAdapterContext,
  ) => MaybePromise<SearchAdapterOutput | SearchAdapterOutput[] | void>
}

type SearchAdapterName = 'json' | 'meilisearch' | 'algolia'
type SearchAdapterInput = SearchAdapterName | SearchAdapter

interface JsonSearchAdapterOptions {
  fileName?: string
  pretty?: boolean
  recordsOnly?: boolean
}

interface MeilisearchAdapterOptions {
  fileName?: string
  pretty?: boolean
  indexUid?: string
  primaryKey?: string
}

interface AlgoliaAdapterOptions {
  fileName?: string
  pretty?: boolean
}

interface ViteSearchPluginOptions extends CreateSearchIndexOptions {
  enabled?: boolean
  virtualModuleId?: string
  adapters?: SearchAdapterInput[]
}

declare function viteSearchPlugin(options?: ViteSearchPluginOptions): Plugin
declare function createSearchIndex(options?: CreateSearchIndexOptions): Promise<SearchIndex>
declare function createJsonSearchAdapter(options?: JsonSearchAdapterOptions): SearchAdapter
declare function createMeilisearchAdapter(options?: MeilisearchAdapterOptions): SearchAdapter
declare function createAlgoliaAdapter(options?: AlgoliaAdapterOptions): SearchAdapter

Normalized Records

The plugin emits three record types:

  • page: one record per indexed page, usually using frontmatter desc as content.
  • heading: one record per heading, useful for jumping directly to a section.
  • content: text grouped under the current heading.

Each record includes both a route path and a URL:

{
  "id": "guide-install-heading-2",
  "type": "heading",
  "title": "Installation",
  "content": "Configure Vite",
  "url": "/guide/install#configure-vite",
  "path": "/guide/install",
  "anchor": "configure-vite",
  "section": "Configure Vite",
  "hierarchy": ["Installation", "Configure Vite"],
  "tags": ["vite", "install"],
  "source": {
    "file": "/absolute/path/src/markdown/guide/install.md",
    "relativeFile": "guide/install.md"
  }
}

The adapter layer receives these records and can reshape them without reparsing Markdown.

Multiple Markdown Roots

Use multiple roots when a docs site combines product docs, package docs, and generated pages:

viteSearchPlugin({
  markdown: [
    {
      root: './src/markdown',
    },
    {
      root: './src/packages',
      routeBase: '/packages',
      exclude: ['drafts/**'],
    },
  ],
})

Each Markdown source can have its own include/exclude rules, route base, frontmatter fields, and record controls.

Frontmatter Controls

Projects can customize which frontmatter fields are used:

viteSearchPlugin({
  markdown: {
    root: './src/markdown',
    frontmatter: {
      titleFields: ['title', 'heading'],
      descriptionFields: ['desc', 'description', 'summary'],
      tagFields: ['tags', 'keys', 'keywords', 'topics'],
      searchField: 'indexed',
      metaFields: ['badge', 'overline', 'packageName'],
    },
  },
})

With that configuration, a page can opt out with indexed: false instead of search: false.

Record Granularity

Disable heading or content records when a project wants a smaller index:

viteSearchPlugin({
  markdown: {
    root: './src/markdown',
    headingRecords: false,
    minContentLength: 24,
  },
})

Use contentRecords: false when a project only needs page and heading matches.

Static JSON Adapter

The default adapter emits the complete index:

import { createJsonSearchAdapter, viteSearchPlugin } from '@md-plugins/vite-search-plugin'

viteSearchPlugin({
  markdown: {
    root: './src/markdown',
  },
  adapters: [
    createJsonSearchAdapter({
      fileName: 'search/search-index.json',
      pretty: true,
    }),
  ],
})

Set recordsOnly: true if a consuming search library expects an array:

createJsonSearchAdapter({
  fileName: 'search/records.json',
  recordsOnly: true,
})

Meilisearch Adapter

The Meilisearch adapter emits upload-ready documents:

import { createMeilisearchAdapter } from '@md-plugins/vite-search-plugin'

createMeilisearchAdapter({
  indexUid: 'docs',
  primaryKey: 'id',
  fileName: 'search/meilisearch.json',
})

The generated file can be uploaded from CI or a Netlify build hook with an admin key. The browser should only receive a search-only key.

import { readFile } from 'node:fs/promises'

const payload = JSON.parse(await readFile('dist/search/meilisearch.json', 'utf8'))

await fetch(`${process.env.MEILI_HOST}/indexes/${payload.indexUid}/documents`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MEILI_ADMIN_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload.documents),
})

Algolia Adapter

The Algolia adapter emits records with objectID, url, content, and a DocSearch-like hierarchy object:

import { createAlgoliaAdapter } from '@md-plugins/vite-search-plugin'

createAlgoliaAdapter({
  fileName: 'search/algolia.json',
})

Upload should happen in a trusted deploy step:

import { readFile } from 'node:fs/promises'
import algoliasearch from 'algoliasearch'

const records = JSON.parse(await readFile('dist/search/algolia.json', 'utf8'))
const client = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY!)
const index = client.initIndex('docs')

await index.replaceAllObjects(records)

Custom Adapters

Adapters are intentionally small. They receive normalized records and return one or more emitted assets:

import { viteSearchPlugin, type SearchAdapter } from '@md-plugins/vite-search-plugin'

const sitemapSearchAdapter: SearchAdapter = {
  name: 'sitemap-search',
  transform(records) {
    const urls = records.filter((record) => record.type === 'page').map((record) => record.url)

    return {
      fileName: 'search/page-urls.json',
      source: JSON.stringify(urls, null, 2),
    }
  },
}

viteSearchPlugin({
  markdown: {
    root: './src/markdown',
  },
  adapters: [sitemapSearchAdapter],
})

A custom adapter is also the right place to produce Pagefind input, a FlexSearch document list, a serverless-function payload, or private search metadata.

Virtual Module

The virtual module can be customized:

viteSearchPlugin({
  markdown: {
    root: './src/markdown',
  },
  virtualModuleId: 'virtual:docs/search',
})

Then import it from application code:

import searchIndex, { searchRecords } from 'virtual:docs/search'

For large documentation sites, prefer the emitted JSON asset so the search payload can be cached and loaded only when the user opens search.

Static Hosting Versus Search Servers

Static JSON output does not need a server. The generated asset is served with the rest of the built site, so it works on Netlify, GitHub Pages, Cloudflare Pages, and similar hosts.

Meilisearch and Algolia output still require a hosted search service at runtime. The plugin only creates the upload data. That keeps md-plugins provider-neutral and keeps service credentials in a trusted CI or deploy environment.