Show navigationHide navigation
Search Features
This CMS includes full-text search for both admin and public surfaces, powered by MiniSearch↗.
Overview#
| Surface | Location | Purpose | Features |
|---|---|---|---|
| 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 API | GET /api/search?q=... | Search visible collections from public site | Pre-built index, resolved URLs, filtered by config |
| Public Component | <SearchBox /> | Drop-in React component for public pages | Keyboard 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, slugstext— long-form textmarkdown— companion.mdfiles (stripped of markup)richtext— companion.mdxfiles (stripped of markup)slug— URL segmentsselect— 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 }
Admin Search#
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#
- Index building: On first search (or after content changes), the CMS builds an in-memory MiniSearch index from all content entries.
- Lazy caching: The index is cached in the content store and invalidated whenever an entry is saved, created, or deleted.
- 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,Enterto 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 byentryAdminHref(result)fromoctocms/lib/searchIndex— the publicresult.urlis 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#
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/searchbuilds (or loads) a MiniSearch JSON string from the published Git ref; optional committedcms/__generated__/search-index.jsonon that ref is used when present - URL resolution: URLs are resolved server-side using
search.publicCollectionsconfig patterns - Filtered results: Only collections in
publicCollectionsappear in results - 404 handling: Returns 404 if no
publicCollectionsare 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: { ... } // } // ]
Public Search Component: <SearchBox />#
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#
typescriptexport 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
tsximport 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
tsximport 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: [...] }); }
Client-side (React components with <SearchBox />)#
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#
| File | Purpose |
|---|---|
octocms/lib/searchIndex.ts | Core search utilities: index building, querying, markup stripping |
octocms/admin/actions/search.ts | Admin search server action |
octocms/components/CommandK/CommandK.tsx | Admin search palette (⌘K overlay) |
src/app/api/search/route.ts | Public search API endpoint |
octocms/components/public/SearchBox.tsx | Public 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) fromresolveContentBranch()— same ref public pages use for content - If
cms/__generated__/search-index.jsonexists 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#
typescriptsearch: { 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#
:fieldNameplaceholders — Replaced withentry.fields[fieldName]:idplaceholder — Special; usesentry.sys.id- Fixed paths — Returned as-is (useful for singletons like homepage)
- Missing fields — Entry excluded from public search if a referenced field is empty
Example#
Config:
typescriptpost: { 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#
Exclude a field from search#
typescriptfields: { internalNotes: { label: 'Internal Notes', format: 'text', searchable: false, // ← Won't appear in search results }, }
Add a collection to public search#
-
Enable it in config:
typescriptsearch: { publicCollections: { myCollection: { urlPattern: '/my-path/:slug' }, }, } -
Save an entry to rebuild the public index
-
Search should now return results from this collection
Change URL pattern for a collection#
-
Update the pattern in
cms/octocms.config.ts:typescriptpost: { urlPattern: '/posts/:slug' } // was '/blog/:slug' -
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:
- Is the
searchablefield set tofalsefor that field? - Are entries saved with content in searchable fields?
- (Admin) Did you reload the app after saving entries?
- (Public) Is the collection in
search.publicCollections?
Entry doesn't appear in public search#
Check:
- Is the collection in
search.publicCollections? - Does the entry have a value for all fields referenced in the URL pattern?
- Example: pattern is
/blog/:slugbut entry has noslugfield → excluded
- Example: pattern is
- 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
fuzzysetting inoctocms/lib/searchIndex.ts(currently 0.2 = 20%)
Performance is slow#
This typically happens with very large search indexes. Optimization strategies:
- Reduce indexable content: Mark fields as
searchable: falseif not needed - Limit public collections: Only include essential collections in
publicCollections - Increase debounce delay: Change
setTimeout(..., 300)to higher value in components
See also#
- Query API — Type-safe content fetching (alternative to search)
- Content model — Field types and entry structure
- Configuration — Full config options