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.

ExampleWhat it showsTry it
Vite + React RouterCommands 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 RouterA '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/uiBoth 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.

App.tsx
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.

tsx
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.

tsx
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:

.husky/pre-commit
npx cmdk-engine scan && git add src/generated/command-routes.json

Or with lint-staged. Use a function task: a plain command string would get the staged file names appended, and scan takes no file arguments.

lint-staged.config.mjs
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.

palette.test.tsx
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.