The smart command palette engine for React. Built on cmdk. Auto-discover routes, fuzzy search with synonyms, RBAC filtering, frecency ranking, CLI tooling — all in < 5KB.
cmdk gives you beautiful, accessible command menu primitives. But building a production command palette requires more:
| Feature | cmdk | cmdk-engine |
|---|---|---|
| Composable UI components | Yes | Yes (via cmdk adapter) |
| Route auto-discovery | No | Yes — CLI scanner + runtime adapters |
| RBAC / permission filtering | No | Yes — any/all modes |
| Frecency ranking | No | Yes — exponential decay algorithm |
| Keyword synonyms | No | Yes — bidirectional, ranked below direct matches |
| Smart route exclusion | No | Yes — auth, error, dynamic routes auto-filtered |
| Deterministic sorting | Broken (#264, #375) | Yes — frecency > priority > alphabetical |
| First item auto-select | Broken (#280) | Yes — auto-selects on every result update |
| Dynamic content updates | Broken (#267) | Yes — reactive pub/sub registry |
| CLI tooling | No | Yes — scan, init, validate |
| Framework-agnostic core | No | Yes — zero runtime deps |
cmdk-engine owns all filtering (shouldFilter={false}), solving the sorting and selection bugs in cmdk while keeping its composable UI primitives.
# npm
npm install cmdk-engine cmdk
# bun
bun add cmdk-engine cmdk
# pnpm
pnpm add cmdk-engine cmdk
# yarn
yarn add cmdk-engine cmdk
cmdkandreactare peer dependencies.
import { CommandEngineProvider } from 'cmdk-engine/react'
function App() {
return (
<CommandEngineProvider
config={{
synonyms: {
billing: ['money', 'payment', 'credits'],
settings: ['preferences', 'config', 'options'],
},
}}
>
<YourApp />
</CommandEngineProvider>
)
}import { useCommandRegister } from 'cmdk-engine/react'
import { CreditCard } from 'lucide-react'
function BillingPage() {
useCommandRegister([
{
id: 'billing-overview',
label: 'Billing Overview',
href: '/billing/overview',
keywords: ['balance', 'credits'],
group: 'Billing',
icon: <CreditCard size={16} />, // React components, strings, or emoji
},
])
return <div>...</div>
}import { CommandPalette } from 'cmdk-engine/adapters/cmdk'
function CommandMenu() {
return (
<CommandPalette
dialog
placeholder="Type a command or search..."
onSelect={(item) => {
if (item.href) navigate(item.href)
if (item.action) item.action(item)
}}
/>
)
}Or use config.onSelect on the provider to handle all selections in one place:
<CommandEngineProvider
config={{
onSelect: (item) => {
if (item.href) navigate(item.href)
if (item.action) item.action(item)
},
}}
>import { useCommandPalette } from 'cmdk-engine/react'
function CustomCommandMenu() {
const { search, setSearch, groupedResults, isOpen, toggle, select } =
useCommandPalette()
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
{groupedResults.map(({ group, items }) => (
<div key={group.id}>
<h3>{group.label}</h3>
{items.map(({ item }) => (
<button key={item.id} onClick={() => select(item)}>
{item.icon} {item.label}
</button>
))}
</div>
))}
</div>
)
}
select()records frecency, runsonSelect/action/href, and closes the palette — all in one call.
Auto-discover routes from your React Router config:
import { scanRoutes } from 'cmdk-engine/adapters/react-router'
import { useCommandRegister } from 'cmdk-engine/react'
const commands = scanRoutes(routeConfig)
function App() {
useCommandRegister(commands)
return <RouterProvider router={router} />
}The scanner automatically:
- Excludes auth routes —
/login,/signup,/forgot-password,/oauth/callback, etc. - Excludes error pages —
/404,/500,/error,/not-found - Skips dynamic routes —
/users/:id,/billing/:uuid(can't navigate without a real ID) - Derives labels from the path —
/billing/overview→ "Overview" - Derives groups from the first segment —
/billing/overview→ group "Billing"
const commands = scanRoutes(routeConfig, {
exclude: ['/admin/*', /^\/debug\//, '/internal'], // string, glob, or regex
noDefaultExclude: false, // set true to skip default auth/error exclusion
includeDynamic: false, // set true to include :id routes
})Enrich routes with metadata using the handle convention:
{
path: '/billing/overview',
handle: {
command: {
label: 'Billing Dashboard',
keywords: ['money', 'payment'],
group: 'Billing',
icon: <CreditCard size={16} />,
priority: 10,
}
},
element: <BillingOverview />,
}Routes with handle.command are always included, even if they have dynamic segments. The scanner also falls back to route.title and route.icon if handle.command doesn't define them.
Filter commands based on user permissions:
import { createSimpleAccessProvider } from 'cmdk-engine'
<CommandEngineProvider
config={{
accessControl: createSimpleAccessProvider(['admin.view', 'billing.read']),
accessCheckMode: 'any', // user needs ANY listed permission
}}
>Commands with permissions: ['admin.view'] will only show for users who have that permission.
Commands you use frequently and recently appear higher in results. No configuration needed — it uses localStorage by default. When you use select(), frecency is recorded automatically.
The algorithm uses exponential decay with a configurable half-life:
score = count * 2^(-timeSinceLastUse / halfLife)
Show a "Recent" group at the top of the palette when the search is empty:
<CommandEngineProvider
config={{
frecency: {
showRecent: true, // inject "Recent" group when search is empty
recentCount: 5, // number of recent items (default: 5)
recentLabel: 'Recent', // group label (default: "Recent")
},
}}
>Auto-discover routes and generate sitemaps for your command palette.
# Initialize config
npx cmdk-engine init
# Scan routes
npx cmdk-engine scan
# Validate config
npx cmdk-engine validate// cmdk-engine.config.ts
import { defineConfig } from 'cmdk-engine'
export default defineConfig({
framework: 'react-router', // or 'nextjs-app', 'nextjs-pages'
routesDir: './src/routes',
output: './src/generated/command-routes.json',
overrides: {
'/billing': { keywords: ['money', 'payment'], group: 'Billing' },
},
exclude: ['/404', '/500', '/_*'],
synonyms: {
billing: ['money', 'payment', 'credits'],
},
}){
"husky": {
"hooks": {
"pre-commit": "npx cmdk-engine scan && git add src/generated/command-routes.json"
}
}
}- run: npx cmdk-engine scan
- run: npx cmdk-engine validateRoute Config ─→ Route Adapter ─→ Command Registry ─→ Keyword Engine
│
├─→ Access Control Filter
│
├─→ Search Engine (fuzzy / match-sorter)
│
└─→ Frecency Ranking
│
▼
Headless API / Hooks
│
▼
UI Adapter (cmdk)
| Import | Size | Purpose |
|---|---|---|
cmdk-engine |
~4KB | Core engine (types, registry, search, keywords, access control, frecency) |
cmdk-engine/react |
~2KB | React hooks (provider, useCommandPalette, useCommandRegister) |
cmdk-engine/adapters/cmdk |
~1KB | Pre-wired cmdk components |
cmdk-engine/adapters/react-router |
~1KB | React Router v6/v7 route scanner |
cmdk-engine/search/match-sorter |
~1KB | Optional match-sorter search backend |
All entry points are tree-shakeable. The core has zero runtime dependencies.
import {
createRegistry, // Command store (pub/sub, useSyncExternalStore compatible)
createFuzzySearch, // Built-in lightweight fuzzy search
createKeywordEngine, // Synonym expansion + user aliases
createAccessFilter, // RBAC filter (any/all modes)
createSimpleAccessProvider, // Permission provider from array/Set
createFrecencyEngine, // Frecency ranking with exponential decay
createGroupManager, // Command group management
defineConfig, // Typed config helper for CLI
} from 'cmdk-engine'import {
CommandEngineProvider, // Context provider
useCommandPalette, // Main hook: search + filter + rank
useCommandRegister, // Register commands from components
useFrecency, // Direct frecency access
} from 'cmdk-engine/react'import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk'
import { scanRoutes } from 'cmdk-engine/adapters/react-router'const {
search, // Current query
setSearch, // Update query
results, // ScoredItem[] (flat)
flatResults, // Same as results
groupedResults, // GroupedResult[] — results grouped by group
groups, // CommandGroup[] — active groups
isOpen, // Palette visibility
open, close, toggle,
select, // Select a command (records frecency + runs handler + closes)
recordUsage, // Record frecency manually
} = useCommandPalette()All types are exported and fully documented:
import type {
CommandItem,
CommandRegistry,
SearchEngine,
ScoredItem,
GroupedResult,
GroupedResults,
AccessControlProvider,
FrecencyOptions,
RecentCommandsConfig,
CommandGroup,
SynonymMap,
RouteCommandMeta,
CmdkEngineConfig,
CommandEngineConfig,
CommandPaletteState,
} from 'cmdk-engine'| Issue | Description | How We Fix It |
|---|---|---|
| #264 | Sort not restored after clearing search | We own filtering; restore original order when query is empty |
| #280 | First item not selected with dynamic content | Auto-select first item after each render cycle |
| #375 | Non-deterministic sorting | Deterministic: frecency → priority → alphabetical |
| #374 | Scroll position jump on filter | We control the result list; reset scroll on search change |
| #267 | Items not updating on async changes | Reactive pub/sub registry; items update immediately |
See CONTRIBUTING.md for guidelines.
If you find cmdk-engine useful, please consider giving it a star on GitHub. It helps others discover the project.