CLI

cmdk-engine scans your route files and writes a sitemap: a file listing every route with a label, keywords and group. Use it when your routes live in files, as in Next.js, or to keep a committed list of routes in sync in CI. It needs Node.js 20 or later.

bash
npx cmdk-engine init      # create cmdk-engine.config.ts
npx cmdk-engine scan      # scan routes and write the sitemap
npx cmdk-engine validate  # check the config file

Frameworks

FrameworkWhat it scansDefault routes directory
react-routerSource files, for route objects and <Route> elements with a string path, with the label, keywords and group of their own handle.command. Relative child paths are joined to their parent route in the same file. Routes with a :param are skipped unless includeDynamic or their own handle.command keeps them, and catch-all routes are always skipped.src/routes if it exists, otherwise src
nextjs-appEach page file under the App Router. Route groups such as (auth) and @slot folders add no segment. Private _folders, intercepting (.) routes and api/ are skipped. [[...slug]] gives its parent's URL, and other dynamic segments need includeDynamic.src/app if it exists, otherwise app
nextjs-pagesEach file under the Pages Router, except files that start with _ (such as _app) and the top-level api/ directory. Dynamic segments need includeDynamic. src/pages if it exists, otherwise pages

When you do not name a framework, a next.config file means Next.js (the App Router if an app or src/app directory exists, otherwise the Pages Router). Anything else is treated as React Router.

Commands

cmdk-engine init

Creates a config file from a template. It refuses to overwrite an existing one unless you pass --force.

OptionDefaultDescription
-c, --config <path>cmdk-engine.config.tsWhere to create the file.
-f, --forceoffOverwrite an existing config file.

cmdk-engine scan

Scans the routes and writes the sitemap. It stops with an error, and leaves the existing output alone, when it finds no routes: a mistyped directory or framework is the usual cause.

OptionDefaultDescription
-c, --config <path>cmdk-engine.config.tsThe config file. Not finding one you named is an error; the default is optional.
-o, --output <path>the config's output, else src/generated/command-routes.jsonWhere to write the sitemap.
-f, --format <format>jsonjson or ts. With ts the file is a module that exports commandRoutes.
--framework <name>auto-detectedreact-router, nextjs-app or nextjs-pages.
--routes-dir <dir>per frameworkThe directory to scan.
--include-dynamic [names...] New in 0.6offKeep routes with these :param segments, for example locale(every :param when no name is given). Names can be separated by spaces or commas. The flag overrides the config value.
--allow-emptyoffWrite the sitemap even when no routes are found.
--no-default-excludeoffKeep the routes that are normally left out (below).

cmdk-engine validate

Checks the config file. It prints Config is valid., or lists the problems and exits with status 1. It also checks that every exclude entry is a string or a RegExp, and that includeDynamic is true, false or an array of names. It takes -c, --config <path>.

Config file

The config file is optional. Without one, everything is detected. Wrap it in defineConfig to type-check it; the type is CmdkEngineConfig, which is not the provider's CommandEngineConfig.

cmdk-engine.config.ts
import { defineConfig } from 'cmdk-engine'

export default defineConfig({
  framework: 'react-router',
  routesDir: './src/routes',
  output: './src/generated/command-routes.json',
  overrides: {
    '/billing/overview': { keywords: ['money', 'payment'], group: 'Billing' },
  },
  exclude: ['/admin/*', '/internal'],
})
FieldDescription
frameworkreact-router, nextjs-app or nextjs-pages. Detected when omitted.
routesDirThe directory to scan.
outputWhere to write the sitemap.
overridesPer-route changes, keyed by path. Only label, keywords (added to the route's own) and group are applied; other fields are ignored.
excludeRoutes to leave out, on top of the defaults. Each entry is an exact string, a glob or a RegExp. In a glob, * matches within one path segment and ** across segments. Excluding a path also excludes the paths below it, and a trailing /* also matches the base: /admin/* excludes /admin and everything under it. /admin* also matches /administration.
includeDynamic New in 0.6true, false or an array of :param names. Keeps routes with those segments, such as everything under a Next.js [locale] folder: ['locale'] gives /:locale/billing. true keeps every :param route, and catch-all segments are never kept. Default false. The --include-dynamic flag overrides it. See Dynamic routes below.
synonymsChecked by validate but not used by scan. Put synonyms in the provider's config.synonyms.

A .js, .mjs or .cjs config is imported, so it runs, and a .json config is read as data. A .ts config is read without running it, so its values must be static: strings, numbers, booleans, arrays, objects, RegExp literals and top-level consts (and spreads of them), with as const and satisfies allowed. For computed values such as process.env, use cmdk-engine.config.mjs. A config the CLI cannot read fails scan and validate with exit status 1 and names the line.

Output

The sitemap lists the routes sorted by path, and each carries the file it came from. With the JSON format, a scan that finds the same routes as the file already on disk keeps that file's generatedAt, so a committed sitemap only changes when your routes do.

src/generated/command-routes.json
{
  "version": 1,
  "generatedAt": "2026-01-01T00:00:00.000Z",
  "framework": "react-router",
  "routes": [
    {
      "id": "billing--overview",
      "path": "/billing/overview",
      "label": "Billing Dashboard",
      "keywords": ["billing", "overview", "money", "payment"],
      "group": "Billing",
      "source": "src/routes/routes.tsx"
    },
    {
      "id": "settings--team",
      "path": "/settings/team",
      "label": "Team",
      "keywords": ["settings", "team", "people", "members"],
      "group": "Settings",
      "source": "src/routes/routes.tsx"
    }
  ]
}

Ids keep letters, digits and -, so /billing/overview gives billing--overview. When two routes would share an id (/a_b and /ab both give ab), the scan keeps it for the last of them in path order, which keeps its frecency and Recent history, gives the others ab-2, ab-3 and so on, and prints a warning for each.

Use the output

sitemapToCommands from cmdk-engine/adapters/sitemap turns the sitemap into commands. Register them once, at the app level:

tsx
import { useCommandRegister } from 'cmdk-engine/react'
import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap'
import sitemap from './generated/command-routes.json'

const routeCommands = sitemapToCommands(sitemap)

function RouteCommands() {
  useCommandRegister(routeCommands)
  return null
}

Each route becomes { id, label, keywords, group, href }, and selecting one calls your onNavigate. Commit the JSON, or run the scan in a prebuild script. The adapter is described on the Adapters page.

Dynamic routes and [locale] New in 0.6

A command needs a real URL, so the scan skips routes with a :param (/users/:id, app/[id]/page.tsx). To keep routes under a segment you can fill at runtime, such as a Next.js [locale] folder, name it in includeDynamic or pass --include-dynamic locale. app/[locale]/billing/page.tsx then becomes /:locale/billing, which params fills:

bash
npx cmdk-engine scan --include-dynamic locale
tsx
import { useMemo } from 'react'
import { useCommandRegister } from 'cmdk-engine/react'
import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap'
import sitemap from './generated/command-routes.json'

export function LocaleRouteCommands({ locale }: { locale: string }) {
  const commands = useMemo(() => sitemapToCommands(sitemap, { params: { locale } }), [locale])
  useCommandRegister(commands)
  return null
}
  • params values are inserted as given, and '' drops the segment (a default locale served without a prefix).
  • Ids keep the placeholder (locale--billing), so frecency is shared across locales.
  • includeDynamic: true keeps every :param route. A route whose param you do not fill is skipped, and catch-alls ([...slug], /docs/*) are never kept.
  • A React Router route with its own handle.command is kept, as with the runtime scanRoutes.

What the scan reads

The scan reads your files without running them.

  • React Router: route objects with a string path (as passed to createBrowserRouter) and <Route path="..."> elements, with the label, keywords and group of their own handle.command. Relative child paths are joined to their parent route in the same file (children arrays and nested <Route>s). It does not read paths built at runtime, routes imported from another file, index routes, or a handle returned by lazy().
  • Next.js: app/**/page.* (nextjs-app) and pages/** (nextjs-pages). Route groups and @slot folders add no segment. Private _folders, intercepting (.) routes and api/ are skipped. [[...slug]] gives its parent's URL; other dynamic routes need includeDynamic.

Routes that are left out

The scanner skips routes that are almost never useful as commands. This is the same list the runtime React Router adapter uses:

  • Auth routes: /login, /logout, /signin, /signout, /signup, /register, /forgot-password, /reset-password and /verify-email.
  • Callbacks: /oauth/callback, /auth/callback and /callback.
  • Error pages: /404, /500, /error and /not-found.

Pass --no-default-exclude to keep them, and use exclude in the config to choose your own.

In CI and on commit

Run the scan in CI and fail when the committed sitemap is out of date. Because a scan of unchanged routes leaves the file untouched, git diff is a reliable check:

yaml
- run: npx cmdk-engine validate
- run: npx cmdk-engine scan
- run: git diff --exit-code src/generated/command-routes.json

To regenerate it on every commit with husky 9, run npx husky init, then put this in .husky/pre-commit:

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

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',
  ],
}