Show navigationHide navigation

Search Features

This CMS includes full-text search for both admin and public surfaces, powered by MiniSearch.

Overview#

SurfaceLocationPurposeFeatures
Admin Search⌘K palette (any admin page)Search every entry across every collection (plus branches and quick actions)Real-time fuzzy/prefix matching, title boosting, ↑/↓ keyboard nav, Enter or click opens the entry editor, ⌘-click opens the editor in a new tab
Public APIGET /api/search?q=...Search visible collections from public sitePre-built index, resolved URLs, filtered by config
Public Component<SearchBox />Drop-in React component for public pagesKeyboard navigation, highlighting, dropdown UI

Configuration#

Enable search in cms/octocms.config.ts#

Search is enabled by default for text-like fields. Control behavior with the search config and per-field searchable flags.

typescript
// cms/octocms.config.ts
import { defineConfig } from './octocms/defineConfig';

export default defineConfig({
  collections: {
    post: {
      label: 'Posts',
      fields: {
        title: {
          label: 'Title',
          format: 'string',
          // searchable defaults to true for 'string' format
        },
        content: {
          label: 'Body',
          format: 'markdown',
          searchable: true, // Optional: explicitly enable
        },
        internalNotes: {
          label: 'Internal Notes',
          format: 'text',
          searchable: false, // Exclude from search
        },
        tags: {
          label: 'Tags',
          format: 'select',
          options: [
            { value: 'feature', label: 'Feature' },
            { value: 'bug', label: 'Bug' },
          ],
          // select is searchable by default
        },
      },
    },
  },

  // PUBLIC SEARCH: Control which collections appear in public search
  search: {
    publicCollections: {
      post: {
        // Map entry fields to public page URLs
        // Placeholders: :fieldName (from entry.fields) or :id (from entry.sys.id)
        urlPattern: '/blog/:slug',
      },
      // Other collections (e.g., author, category) are excluded from public search
    },
  },
});

Field-level searchability#

By default, these formats are searchable:

  • string — titles, names, slugs
  • text — long-form text
  • markdown — companion .md files (stripped of markup)
  • richtext — companion .mdx files (stripped of markup)
  • slug — URL segments
  • select — option labels (both single and multi-select)

These formats are not searchable by default:

  • url, image, boolean, number, datetime, json, color, reference, conditional

Override with the searchable field option:

typescript
{
  label: 'Secret Field',
  format: 'text',
  searchable: false, // Exclude despite text format
}

The ⌘K palette is the single admin search surface — a centered overlay that opens on any admin page and lists matching entries, branches, and quick actions in one card.

Search is real-time as you type (120 ms debounce). Every entry in every collection is indexed — including entries whose public urlPattern references a missing field; admin search keeps them, public search filters them out.

How it works#

  1. Index building: On first search (or after content changes), the CMS builds an in-memory MiniSearch index from all content entries.
  2. Lazy caching: The index is cached in the content store and invalidated whenever an entry is saved, created, or deleted.
  3. Filesystem vs. Store: In dev mode, search reads from the local filesystem. In production, it uses the content store (which maintains per-branch state).

Features#

  • Fuzzy matching: Typos and partial words are matched (20% fuzzy tolerance)
  • Prefix search: Type "post" → matches "postcard", "posterize", etc.
  • Title boosting: Matches in entry titles are ranked higher (2x boost)
  • Snippets: First 200 characters of matching content shown in results
  • Keyboard: / to move the active row, Enter to open the entry in the editor. -click (or middle-click) opens the editor in a new tab — handled natively by the row's underlying <Link>
  • Navigation: Click a row → jump to the entry editor at /cms/content/<type>/<filename-stem>. The href is built by entryAdminHref(result) from octocms/lib/searchIndex — the public result.url is informational only and never becomes the click target

Code example: Using the search action#

The admin search is a Server Action, so you can call it from any Server Component or client component via a Server Action wrapper:

typescript
// In a Server Component or via a Server Action
import { searchEntries } from 'octocms/admin/actions/search';

// Call the action with a query string
const results = await searchEntries('my search term');

// Results include:
// - title: Entry title
// - type: Collection name (e.g. 'post')
// - typeLabel: Collection label (e.g. 'Posts')
// - id: Entry ID (format: "collection/entryid")
// - url: Resolved public URL (public search only)
// - snippet: First 200 chars of content
// - score: Relevance score (higher = better match)
// - match: {fieldName: ['term1', 'term2', ...]} - which fields matched
console.log(results);
// [
//   {
//     type: 'post',
//     typeLabel: 'Posts',
//     id: 'post/my-first-post',
//     title: 'My First Post',
//     score: 8.5,
//     match: { title: ['my'], content: ['my', 'search'] },
//     url: '/blog/my-first-post',
//     snippet: 'This is my search result about...',
//   }
// ]

Public search allows visitors to search only the collections you've enabled in the search.publicCollections config.

API Route: /api/search#

bash
# Request
GET /api/search?q=hello&limit=10

# Response
{
  "results": [
    {
      "type": "post",
      "typeLabel": "Posts",
      "id": "post/abc123",
      "title": "Hello World",
      "url": "/blog/hello-world",
      "score": 8.2,
      "snippet": "Hello, welcome to my blog...",
      "match": { "title": ["hello"], "content": ["hello"] }
    }
  ]
}

Features#

  • Index at request time: GET /api/search builds (or loads) a MiniSearch JSON string from the published Git ref; optional committed cms/__generated__/search-index.json on that ref is used when present
  • URL resolution: URLs are resolved server-side using search.publicCollections config patterns
  • Filtered results: Only collections in publicCollections appear in results
  • 404 handling: Returns 404 if no publicCollections are configured

Code example: Calling the API#

typescript
// From a client component
const response = await fetch('/api/search?q=blog&limit=20');
const data = await response.json();

console.log(data.results);
// [
//   {
//     type: 'post',
//     typeLabel: 'Posts',
//     id: 'post/my-post',
//     title: 'My Blog Post',
//     url: '/blog/my-blog-post',  // Pre-resolved from config
//     score: 9.1,
//     snippet: 'Blog post content snippet...',
//     match: { ... }
//   }
// ]

Drop-in React component for adding search to your public pages.

Installation#

Already included if you're using the CMS's base dependencies.

Basic usage#

tsx
'use client';

import SearchBox from 'octocms/components/public/SearchBox';

export default function Header() {
  return (
    <header className="flex items-center gap-4">
      <h1>My Site</h1>
      <SearchBox placeholder="Search posts..." className="max-w-md" />
    </header>
  );
}

Features#

  • Keyboard navigation: ↑↓ arrow keys, Enter to select, Esc to close
  • Debounced search: 300ms delay to reduce API calls
  • Highlighting: Search terms highlighted in results with <mark> tags
  • Snippet display: Shows first 200 chars of matching content
  • Type label badges: Collection type shown in results
  • Dark mode: Fully styled for light and dark themes
  • Click-outside close: Dropdown closes when clicking outside
  • Clear button: ✕ button to clear search (appears when query is active)

Props#

typescript
export interface SearchBoxProps {
  /** Placeholder text for the input. Default: "Search..." */
  placeholder?: string;
  
  /** Optional `className` for the container (e.g. from CSS Modules). Default: "" */
  className?: string;
}

Examples#

Search bar in header

tsx
import SearchBox from 'octocms/components/public/SearchBox';

import styles from './header.module.css';

export function Header() {
  return (
    <header className={styles.header}>
      <div className={styles.bar}>
        <h1>Blog</h1>
        <SearchBox className={styles.search} />
      </div>
    </header>
  );
}

Search bar on dedicated page

tsx
// app/search/page.tsx
'use client';

import SearchBox from 'octocms/components/public/SearchBox';

import styles from './search.module.css';

export default function PublicSearchPage() {
  return (
    <div className={styles.wrap}>
      <h1 className={styles.title}>Search</h1>
      <SearchBox placeholder="Find posts, articles, and more..." className={styles.search} />
    </div>
  );
}

Styled search in sidebar

tsx
import SearchBox from 'octocms/components/public/SearchBox';

import styles from './sidebar.module.css';

export function Sidebar() {
  return (
    <aside className={styles.aside}>
      <h3 className={styles.heading}>Quick Search</h3>
      <SearchBox placeholder="Type to search..." className={styles.search} />
    </aside>
  );
}

How to handle search results in components#

Server-side (Next.js API routes, Server Components)#

typescript
// src/app/api/search/advanced/route.ts
import { querySearchIndex } from 'octocms/lib/searchIndex';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');
  const category = searchParams.get('category');

  // Your custom search logic here
  // You can filter results, apply ranking, etc.

  return Response.json({ results: [...] });
}

The <SearchBox /> component handles all API calls internally. To use results:

tsx
'use client';

import { useEffect, useState } from 'react';
import type { SearchResult } from 'octocms/lib/searchIndex';

export function CustomSearchUI() {
  const [results, setResults] = useState<SearchResult[]>([]);
  const [query, setQuery] = useState('');

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const timer = setTimeout(async () => {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      if (response.ok) {
        const data = await response.json();
        setResults(data.results || []);
      }
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((result) => (
          <li key={result.id}>
            <a href={result.url}>{result.title}</a>
            <p>{result.snippet}</p>
            <span>{result.typeLabel}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

Architecture#

Core files#

FilePurpose
octocms/lib/searchIndex.tsCore search utilities: index building, querying, markup stripping
octocms/admin/actions/search.tsAdmin search server action
octocms/components/CommandK/CommandK.tsxAdmin search palette (⌘K overlay)
src/app/api/search/route.tsPublic search API endpoint
octocms/components/public/SearchBox.tsxPublic search component (client)

Data flow#

User types in search input
  
300ms debounce
  
Server Action / API call
  
Build or load index
  
MiniSearch.search(query)
  
Filter & rank results
  
Return to client
  
Render results with highlighting

Indexing#

Admin (dev mode):

  • Triggered on first search query after app start
  • Reads all entries from filesystem
  • Cached in memory, invalidated when entries change
  • Includes all collections

Public (production):

  • Built inside GET /api/search (cached ~5 minutes per deploy instance) from resolveContentBranch() — same ref public pages use for content
  • If cms/__generated__/search-index.json exists on that ref, it is used; otherwise entries are read over the GitHub API and the index is assembled in memory
  • Only includes collections in search.publicCollections
  • URLs pre-resolved from config patterns

Public (development): uses committed cms/__generated__/search-index.json when non-empty; otherwise indexes from the local cms/content/ tree.

URL Pattern Resolution#

When configuring public search, specify how to map entry fields to page URLs.

Pattern syntax#

typescript
search: {
  publicCollections: {
    post: {
      urlPattern: '/blog/:slug',  // ↑ replaced with entry.fields.slug
    },
    page: {
      urlPattern: '/pages/:id',   // ↑ special: uses entry.sys.id
    },
    doc: {
      urlPattern: '/',            // Fixed path (singletons)
    },
  },
}

How resolution works#

  1. :fieldName placeholders — Replaced with entry.fields[fieldName]
  2. :id placeholder — Special; uses entry.sys.id
  3. Fixed paths — Returned as-is (useful for singletons like homepage)
  4. Missing fields — Entry excluded from public search if a referenced field is empty

Example#

Config:

typescript
post: { urlPattern: '/blog/:slug' }

Entry:

json
{
  "sys": { "id": "post-123", "type": "post" },
  "fields": { "title": "My Post", "slug": "my-post" }
}

Result URL:

/blog/my-post

If slug field is empty, the entry is excluded from public search results.

Common tasks#

typescript
fields: {
  internalNotes: {
    label: 'Internal Notes',
    format: 'text',
    searchable: false,  // ← Won't appear in search results
  },
}
  1. Enable it in config:

    typescript
    search: {
      publicCollections: {
        myCollection: { urlPattern: '/my-path/:slug' },
      },
    }
  2. Save an entry to rebuild the public index

  3. Search should now return results from this collection

Change URL pattern for a collection#

  1. Update the pattern in cms/octocms.config.ts:

    typescript
    post: { urlPattern: '/posts/:slug' }  // was '/blog/:slug'
  2. Save an entry to rebuild the index (URLs are re-resolved)

Customize search results rendering#

Use the API directly instead of <SearchBox />:

tsx
'use client';

import { useEffect, useState } from 'react';
import type { SearchResult } from 'octocms/lib/searchIndex';

export function MyCustomSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);

  const handleSearch = async (query: string) => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=20`);
    const data = await res.json();
    setResults(data.results);
  };

  return (
    <div>
      <input type="text" onChange={(e) => handleSearch(e.target.value)} />
      <div className="my-custom-results-style">
        {results.map((r) => (
          <CustomResultCard key={r.id} result={r} />
        ))}
      </div>
    </div>
  );
}

Performance & optimization#

  • Lazy index building: Admin search index built only on first search, not on app startup
  • Debouncing: 300ms debounce on search input prevents excessive API calls
  • Serialization: Index serialized to JSON for instant loading (no re-parsing on each query)
  • Field-level filtering: Only searchable fields indexed, reducing index size
  • Snippet extraction: Content truncated to 200 chars for smaller API response

Troubleshooting#

Search results are empty#

Check:

  1. Is the searchable field set to false for that field?
  2. Are entries saved with content in searchable fields?
  3. (Admin) Did you reload the app after saving entries?
  4. (Public) Is the collection in search.publicCollections?

Entry doesn't appear in public search#

Check:

  1. Is the collection in search.publicCollections?
  2. Does the entry have a value for all fields referenced in the URL pattern?
    • Example: pattern is /blog/:slug but entry has no slug field → excluded
  3. Are searchable fields filled in?

Typos aren't matched#

MiniSearch uses fuzzy matching with 20% tolerance. Very different spellings won't match. You can:

  • Type more characters (fuzzy matching improves with more context)
  • Use the exact spelling
  • Check the fuzzy setting in octocms/lib/searchIndex.ts (currently 0.2 = 20%)

Performance is slow#

This typically happens with very large search indexes. Optimization strategies:

  1. Reduce indexable content: Mark fields as searchable: false if not needed
  2. Limit public collections: Only include essential collections in publicCollections
  3. Increase debounce delay: Change setTimeout(..., 300) to higher value in components

See also#