Examples
Common patterns and integration examples for cmdk-engine.
Runnable apps
The examples folder has three apps. Each one installs cmdk-engine from npm, so you can copy it out of the repo or open it in StackBlitz.
| Example | What it shows | Try it |
|---|---|---|
| Vite + React Router | Commands from the route tree with scanRoutes, the provider inside the router, the CSS from Styling, and the same palette on Base UI with ?adapter=base-ui. | StackBlitz |
| Next.js App Router | A 'use client' provider file under a server layout, router.push as onNavigate, and [locale] pages found by cmdk-engine scan --include-dynamic locale and filled in with sitemapToCommands. See the Next.js guide. | StackBlitz |
| shadcn/ui | Both registry items from the shadcn/ui page, in an app set up with shadcn init, in light and dark mode. | StackBlitz |
React Router Integration
Auto-discover routes from your React Router config and register them as commands.
import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react'
import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk'
import { scanRoutes } from 'cmdk-engine/adapters/react-router'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
const routes = [
{
path: '/dashboard',
element: <h1>Dashboard</h1>,
handle: {
command: { label: 'Dashboard', group: 'Navigation' }
}
},
{
path: '/billing',
element: <h1>Billing</h1>,
handle: {
command: {
label: 'Billing',
keywords: ['payment', 'invoice'],
group: 'Navigation'
}
}
},
]
const router = createBrowserRouter(routes)
// Scan with options — exclude paths, skip dynamic routes by default
const commands = scanRoutes(routes, {
exclude: ['/admin/*'], // string, glob, or regex
})
// The provider sits outside RouterProvider, so navigate with the router
// object (useNavigate() only works inside the router).
const config = {
onNavigate: (href: string) => router.navigate(href),
frecency: { showRecent: true },
}
function App() {
return (
<CommandEngineProvider config={config}>
<RegisterRoutes />
<CommandMenu />
<RouterProvider router={router} />
</CommandEngineProvider>
)
}
function RegisterRoutes() {
useCommandRegister(commands)
return null
}
function CommandMenu() {
useCommandPaletteShortcut() // Cmd+K / Ctrl+K
return <CommandPalette dialog />
}On React Router 8 there is no react-router-dom: import createBrowserRouter from react-router and RouterProvider from react-router/dom.
With RBAC
Filter commands based on user permissions. Commands with restricted permissions are automatically hidden.
import { createSimpleAccessProvider } from 'cmdk-engine'
import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react'
// User permissions from your auth system. Build the provider once, outside
// the component (or useMemo it per user): a new object on every render
// rebuilds the engine.
const userPermissions = ['billing.read', 'settings.read']
const config = {
accessControl: createSimpleAccessProvider(userPermissions),
accessCheckMode: 'any' as const,
}
export function Providers({ children }: { children: React.ReactNode }) {
return <CommandEngineProvider config={config}>{children}</CommandEngineProvider>
}
// A command that requires admin permission
export function AdminCommands() {
useCommandRegister([{
id: 'admin-panel',
label: 'Admin Panel',
permissions: ['admin.access'], // hidden from non-admin users
href: '/admin',
}])
return null
}Hiding a command is a UI concern, not a security boundary. Always enforce permissions on the server.
Custom UI (without cmdk)
Build a fully custom command palette UI using only the headless hooks. Use groupedResults for pre-grouped items and select() as a one-call handler.
import { useCommandPalette } from 'cmdk-engine/react'
function CustomPalette() {
const { search, setSearch, groupedResults, isOpen, toggle, select } =
useCommandPalette()
if (!isOpen) return null
return (
<div className="palette-overlay" onClick={() => toggle()}>
<div className="palette-content" onClick={e => e.stopPropagation()}>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search..."
autoFocus
/>
{groupedResults.map(({ group, items }) => (
<div key={group.id}>
<h3>{group.label}</h3>
<ul>
{items.map(({ item }) => (
<li key={item.id} onClick={() => select(item)}>
{item.icon && <span>{item.icon}</span>}
<span>{item.label}</span>
{item.description && (
<span className="desc">{item.description}</span>
)}
</li>
))}
</ul>
</div>
))}
{groupedResults.length === 0 && (
<p className="empty">No results</p>
)}
</div>
</div>
)
}CLI: Pre-commit Hook
Auto-scan routes on every commit to keep your command sitemap up to date. With husky 9, run npx husky init, then put the command in .husky/pre-commit:
npx cmdk-engine scan && git add src/generated/command-routes.jsonOr with lint-staged. Use a function task: a plain command string would get the staged file names appended, and scan takes no file arguments.
export default {
'src/routes/**/*.{ts,tsx}': () => [
'npx cmdk-engine scan',
'git add src/generated/command-routes.json',
],
}Testing
jsdom lacks two browser APIs that cmdk uses, so tests that render the cmdk adapter need stubs. Without them the first render throws ResizeObserver is not defined. The Base UI adapter needs no stubs.
import { beforeAll, expect, test } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react'
import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk'
// jsdom lacks these two browser APIs, which cmdk uses
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
Element.prototype.scrollIntoView = () => {}
})
function Commands() {
useCommandRegister([{ id: 'billing', label: 'Billing Overview', href: '/billing' }])
return null
}
function Palette() {
useCommandPaletteShortcut()
return <CommandPalette dialog />
}
test('Cmd+K opens the palette', async () => {
render(
<CommandEngineProvider>
<Commands />
<Palette />
</CommandEngineProvider>,
)
expect(screen.queryByText('Billing Overview')).toBeNull()
fireEvent.keyDown(document, { key: 'k', metaKey: true })
expect(await screen.findByText('Billing Overview')).toBeTruthy()
})Frecency and search history persist to localStorage (cmdk-frecency and cmdk-search-history). Clear them between tests, or pass frecency.storage, so one test's selections do not rank the next test's results. The Base UI dialog stays in the DOM for a moment after Escape, so use waitFor before asserting that it is gone.