A powerful command menu plugin for Payload CMS that enhances navigation and accessibility within the admin panel. Quickly search and navigate through collections, globals, and custom actions using keyboard shortcuts.
✨ Quick Search - Instantly search across all collections and globals
⌨️ Keyboard Shortcuts - Fully customizable keyboard shortcuts powered by react-hotkeys-hook
🔍 Collection Submenu - Search within collection documents by their title field
🎨 Custom Icons - Use any Lucide icon for collections and globals
🎯 Custom Items - Add custom actions and menu groups
🔒 Access Control - The menu shows only what a user may open, and custom items can guard themselves
🌍 i18n Support - Built-in English and Ukrainian translations, easily add your own
🖥️ Cross-platform - Optimized shortcuts for both macOS and Windows/Linux
npm install @veiag/payload-cmdk
# or
yarn add @veiag/payload-cmdk
# or
pnpm add @veiag/payload-cmdkThe plugin works out of the box with minimal configuration:
import { payloadCmdk } from '@veiag/payload-cmdk'
import { buildConfig } from 'payload'
export default buildConfig({
// ... your config
plugins: [
payloadCmdk({
// Plugin works without any options!
}),
],
})This will:
- Add a search button to the admin panel
- Enable
⌘K(Mac) /Ctrl+K(Windows/Linux) keyboard shortcut - List all collections and globals in the command menu
- Enable collection submenu search
import { payloadCmdk } from '@veiag/payload-cmdk'
import { buildConfig } from 'payload'
export default buildConfig({
plugins: [
payloadCmdk({
// Keyboard shortcut to open the menu
shortcut: ['meta+k', 'ctrl+k'], // Default
// Search button configuration
searchButton: {
position: 'actions', // 'actions' | 'nav'
},
// Backdrop blur effect
blurBg: true, // Default
// Collection submenu configuration
submenu: {
enabled: true, // Default
shortcut: 'shift+enter', // 'shift+enter' | 'enter'
icons: {
posts: 'FileText',
users: 'User',
},
},
// Custom icons for collections and globals
icons: {
collections: {
posts: 'FileText',
pages: 'File',
media: 'Image',
users: 'Users',
},
globals: {
settings: 'Settings',
navigation: 'Menu',
},
},
// Collections/globals to ignore
slugsToIgnore: ['payload-migrations', 'payload-preferences'],
// Custom menu items
customItems: [
{
type: 'group',
title: 'Quick Actions',
items: [
{
type: 'item',
slug: 'view-site',
label: 'View Site',
icon: 'ExternalLink',
action: {
type: 'link',
href: 'https://your-site.com',
},
},
{
type: 'item',
slug: 'clear-cache',
label: 'Clear Cache',
icon: 'Trash2',
action: {
type: 'api',
method: 'POST',
href: '/api/cache/clear',
},
},
],
},
],
// Disable the plugin
disabled: false, // Default
}),
],
})Keyboard shortcut to open the command menu. Powered by react-hotkeys-hook.
- Type:
string | string[] - Default:
['meta+k', 'ctrl+k']
The default provides cross-platform support:
meta+k- Works on macOS (⌘K)ctrl+k- Works on Windows/Linux (Ctrl+K)
Examples:
// Single shortcut
shortcut: 'ctrl+shift+k'
// Multiple shortcuts for cross-platform support
shortcut: ['meta+k', 'ctrl+k']
// Custom combinations
shortcut: ['meta+/', 'ctrl+/']Configuration for the search button displayed in the admin panel.
- Type:
{ position?: 'actions' | 'nav' } | false - Default:
{ position: 'actions' }
Options:
position: 'actions'- Display in the action buttons area (default)position: 'nav'- Display in the navigation sidebarfalse- Hide the search button completely (keyboard shortcut still works)
Examples:
// Display in navigation
searchButton: {
position: 'nav'
}
// Hide search button
searchButton: falseActions button position:
Navigation button position:
Enable backdrop blur effect when the command menu is open.
- Type:
boolean - Default:
true
blurBg: false // Disable blur effectConfigure submenu behavior for searching within collection documents.
- Type:
object - Default:
{ enabled: true, shortcut: 'shift+enter' }
Options:
| Property | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable/disable submenu functionality |
shortcut |
'shift+enter' | 'enter' |
'shift+enter' |
Keyboard shortcut to open submenu |
icons |
object |
undefined |
Custom icons for collection submenus |
Shortcut behavior:
shift+enter: Shift+Enter opens submenu, Enter navigates to collection listenter: Enter opens submenu, Shift+Enter navigates to collection list
Example:
submenu: {
enabled: true,
shortcut: 'enter',
icons: {
posts: 'FileText',
products: 'ShoppingCart',
}
}The submenu searches documents by their useAsTitle field (or id if not specified). You can configure this in your collection:
{
slug: 'posts',
admin: {
useAsTitle: 'title' // Submenu will search by this field
}
}Customize icons for collections and globals using Lucide icon names.
- Type:
object - Default:
{ collections: {}, globals: {} }
Default icons:
- Collections:
Filesicon - Globals:
Globeicon
Example:
icons: {
collections: {
posts: 'FileText',
pages: 'File',
media: 'Image',
users: 'Users',
categories: 'Folder',
},
globals: {
settings: 'Settings',
navigation: 'Menu',
footer: 'Layout',
}
}Browse all available icons at lucide.dev/icons.
Add custom menu items and groups to the command menu.
- Type:
Array<CustomMenuItem | CustomMenuGroup> - Default:
[]
{
type: 'item',
slug: 'unique-slug', // Must be unique across all items
label: 'Item Label', // Can be a localized object — see Localization below
icon: 'LucideIconName', // Optional, from lucide.dev/icons
collectionSlugs: ['posts'], // Optional — only show on these collection pages
collectionContext: ['list'], // Optional — 'list', 'document', or both
access: ({ req }) => true, // Optional — server-side check, see Access Control below
action: { ... } // See Action Types below
}Groups are rendered with a heading and can contain multiple items. Groups with identical titles are automatically merged.
{
type: 'group',
title: 'Group Title', // Can be a localized object
collectionSlugs: ['posts'], // Optional — only show on these collection pages
collectionContext: ['list'], // Optional
access: ({ req }) => true, // Optional — server-side check, see Access Control below
items: [
// Array of CustomMenuItem
]
}The menu never offers a user something they cannot open.
Collections and globals are filtered the same way Payload filters its own navigation: a
collection appears only if the user passes its read access and it is not excluded by
admin.hidden. Nothing needs configuring for this.
Custom items and groups answer for themselves through an optional access function:
customItems: [
{
type: 'item',
slug: 'purge-cache',
label: 'Purge Cache',
access: ({ req }) => req.user?.role === 'admin',
action: { type: 'api', href: '/api/cache/purge', method: 'POST' },
},
]The function receives the item itself and a req carrying the current user, so it can reach
the Local API:
access: async ({ req }) => {
const { totalDocs } = await req.payload.count({
collection: 'invoices',
where: { owner: { equals: req.user?.id } },
})
return totalDocs > 0
}Three things are worth knowing:
Denial is the safe answer. If the function throws, the item is dropped.
A denied group takes its items with it, whatever their own access says. An allowed group still filters each item, and a group left with no items is dropped.
Every item requires an action that determines what happens when the item is selected.
Navigates to a URL. Supports both internal paths and external URLs.
action: {
type: 'link',
href: '/admin/collections/posts', // or 'https://your-site.com'
}Makes an HTTP request when the item is selected. The menu closes after the request completes (or fails).
action: {
type: 'api',
href: '/api/cache/clear',
method: 'POST', // 'GET' | 'POST' | 'PUT' | 'DELETE' — default: 'GET'
body: { scope: 'all' }, // Optional JSON body
}Because the plugin config is serialized across the Next.js server→client boundary, a handler that runs in the browser cannot live directly in payload.config.ts. Instead, you reference a handler by a string key in the config and register the actual function on the client. (The access functions above are the exception: they never cross that boundary, because they run on the server.)
Step 1 — reference the key in your config:
// payload.config.ts
customItems: [
{
type: 'item',
slug: 'save-document',
label: 'Save Document',
icon: 'Save',
collectionContext: ['document'],
action: {
type: 'function',
key: 'save-current-doc', // Must match the key used in registerCommandMenuAction
},
},
]Step 2 — register the handler in a client component:
// e.g. app/(payload)/layout.tsx or a custom admin component
'use client'
import { registerCommandMenuAction, unregisterCommandMenuAction } from '@veiag/payload-cmdk/client'
import { useEffect } from 'react'
export default function AdminLayout({ children }) {
useEffect(() => {
registerCommandMenuAction('save-current-doc', () => {
// Submit the currently active form (Payload's save button)
document.querySelector<HTMLButtonElement>('button[type="submit"]')?.click()
})
// Clean up when the component unmounts
return () => unregisterCommandMenuAction('save-current-doc')
}, [])
return <>{children}</>
}The handler can be synchronous or return a Promise. The menu always closes after it resolves (or throws).
Full example with multiple function actions:
// payload.config.ts
customItems: [
{
type: 'group',
title: 'Document Actions',
collectionContext: ['document'],
items: [
{
type: 'item',
slug: 'save-document',
label: 'Save Document',
icon: 'Save',
action: { type: 'function', key: 'save-current-doc' },
},
{
type: 'item',
slug: 'copy-doc-link',
label: 'Copy Document Link',
icon: 'Link',
action: { type: 'function', key: 'copy-doc-link' },
},
],
},
{
type: 'item',
slug: 'import-posts',
label: 'Import Posts',
icon: 'Upload',
collectionSlugs: ['posts'],
collectionContext: ['list'],
action: { type: 'function', key: 'import-posts' },
},
]// Client component / layout
'use client'
import { registerCommandMenuAction, unregisterCommandMenuAction } from '@veiag/payload-cmdk/client'
import { useEffect } from 'react'
export default function AdminLayout({ children }) {
useEffect(() => {
registerCommandMenuAction('save-current-doc', () => {
document.querySelector<HTMLButtonElement>('button[type="submit"]')?.click()
})
registerCommandMenuAction('copy-doc-link', () => {
navigator.clipboard.writeText(window.location.href)
})
registerCommandMenuAction('import-posts', async () => {
await fetch('/api/posts/import', { method: 'POST' })
})
return () => {
unregisterCommandMenuAction('save-current-doc')
unregisterCommandMenuAction('copy-doc-link')
unregisterCommandMenuAction('import-posts')
}
}, [])
return <>{children}</>
}Note: If a key is used in the config but no handler has been registered, the plugin logs a warning to the console and the menu still closes normally.
Show an item or group only when the user is viewing a particular collection.
{
type: 'item',
slug: 'export-posts',
label: 'Export Posts',
icon: 'Download',
collectionSlugs: ['posts'], // only visible on /admin/collections/posts/*
action: { type: 'function', key: 'export-posts' },
}Omit collectionSlugs (or pass an empty array) to show the item on all pages, including non-collection pages.
Use collectionContext on any item or group to control exactly where inside a collection it appears.
- Type:
('document' | 'list')[] - Default: not set (appears on all pages)
| Value | Where it appears |
|---|---|
'list' |
Collection list page — e.g. /admin/collections/posts |
'document' |
Document edit or create page — e.g. /admin/collections/posts/abc123 or /admin/collections/posts/create |
Pass both values, or omit the field entirely, to show the item on all collection pages.
collectionContext can be combined with collectionSlugs to target a specific collection and a specific page context, or used alone to apply to all collections.
Examples:
customItems: [
// Only on the posts list page
{
type: 'item',
slug: 'import-posts',
label: 'Import Posts',
icon: 'Upload',
collectionSlugs: ['posts'],
collectionContext: ['list'],
action: { type: 'function', key: 'import-posts' },
},
// Only when editing or creating a post
{
type: 'item',
slug: 'save-post',
label: 'Save Post',
icon: 'Save',
collectionSlugs: ['posts'],
collectionContext: ['document'],
action: { type: 'function', key: 'save-current-doc' },
},
// On any document edit/create page across all collections
{
type: 'item',
slug: 'copy-link',
label: 'Copy Document Link',
icon: 'Link',
collectionContext: ['document'],
action: { type: 'function', key: 'copy-doc-link' },
},
// On both list and document pages (same as omitting collectionContext)
{
type: 'item',
slug: 'help',
label: 'Open Help',
icon: 'HelpCircle',
collectionSlugs: ['posts'],
collectionContext: ['list', 'document'],
action: { type: 'link', href: '/docs' },
},
]Example with localization:
customItems: [
{
type: 'group',
title: {
en: 'Quick Actions',
uk: 'Швидкі дії',
},
items: [
{
type: 'item',
slug: 'view-site',
label: {
en: 'View Site',
uk: 'Переглянути сайт',
},
icon: 'ExternalLink',
action: {
type: 'link',
href: 'https://your-site.com',
},
},
{
type: 'item',
slug: 'regenerate',
label: 'Regenerate Cache',
icon: 'RefreshCw',
action: {
type: 'api',
method: 'POST',
href: '/api/cache/regenerate',
},
},
],
},
]Specify which collection/global slugs to exclude from the command menu.
- Type:
CollectionSlug[] | { ignoreList: CollectionSlug[], replaceDefaults?: boolean } - Default:
['payload-migrations', 'payload-preferences', 'payload-locked-documents']
Examples:
// Add to default ignore list
slugsToIgnore: ['internal-collection', 'test-data']
// Replace default ignore list completely
slugsToIgnore: {
ignoreList: ['my-hidden-collection'],
replaceDefaults: true
}Completely disable the plugin.
- Type:
boolean - Default:
false
disabled: process.env.DISABLE_COMMAND_MENU === 'true'The plugin includes built-in translations for:
- 🇬🇧 English (
en) - 🇺🇦 Ukrainian (
uk)
You can add translations for other languages using Payload's i18n configuration:
import { buildConfig } from 'payload'
export default buildConfig({
i18n: {
supportedLanguages: {
//You can learn more about adding languages in the Payload docs
en,
uk,
de,
fr,
},
translations: {
de: {
cmdkPlugin: {
loading: 'Lädt...',
navigate: 'zum Navigieren',
noResults: 'Keine Ergebnisse gefunden',
open: 'zum Öffnen',
search: 'Sammlungen, Globals durchsuchen...',
searchIn: 'Suchen in {{label}}',
searchInCollection: 'in Sammlung suchen',
searchShort: 'Suchen',
},
},
fr: {
cmdkPlugin: {
loading: 'Chargement...',
navigate: 'pour naviguer',
noResults: 'Aucun résultat trouvé',
open: 'pour ouvrir',
search: 'Rechercher collections, globals...',
searchIn: 'Rechercher dans {{label}}',
searchInCollection: 'pour rechercher dans la collection',
searchShort: 'Rechercher',
},
},
},
},
plugins: [
payloadCmdk({
// Your config
}),
],
})All translation keys are under the cmdkPlugin namespace:
| Key | Description | Example (EN) |
|---|---|---|
search |
Main search placeholder | "Search collections, globals..." |
searchShort |
Short search label | "Search" |
searchIn |
Submenu search placeholder | "Search in {{label}}" |
loading |
Loading state | "Loading..." |
noResults |
No results state | "No results found" |
navigate |
Footer hint for navigation | "to navigate" |
searchInCollection |
Footer hint for collection search | "to search in collection" |
open |
Footer hint for opening documents | "to open" |
| Shortcut | Action |
|---|---|
⌘K / Ctrl+K |
Open/close command menu |
Esc |
Close menu or go back in submenu |
↑ ↓ |
Navigate items |
Enter |
Select item or navigate to collection |
Shift+Enter |
Search within collection (default) |
| Shortcut | Action |
|---|---|
Esc |
Go back to main menu |
Enter |
Open selected document |
export default buildConfig({
plugins: [payloadCmdk()],
})export default buildConfig({
plugins: [
payloadCmdk({
shortcut: ['meta+/', 'ctrl+/'],
searchButton: false, // Hide button, only use keyboard
}),
],
})export default buildConfig({
plugins: [
payloadCmdk({
customItems: [
{
type: 'item',
slug: 'documentation',
label: 'View Documentation',
icon: 'BookOpen',
action: {
type: 'link',
href: 'https://docs.your-site.com',
},
},
],
}),
],
})export default buildConfig({
plugins: [
payloadCmdk({
icons: {
collections: {
posts: 'Newspaper',
pages: 'FileText',
media: 'Image',
categories: 'FolderTree',
tags: 'Tag',
users: 'UserCircle',
comments: 'MessageCircle',
},
globals: {
header: 'LayoutTemplate',
footer: 'Layout',
settings: 'Settings',
navigation: 'Menu',
seo: 'Search',
},
},
submenu: {
enabled: true,
icons: {
posts: 'FileText',
pages: 'File',
media: 'Image',
},
},
}),
],
})Objects are not valid as a React childerror: Ensure youradmin.useAsTitlefield is a string and not an object. Currently, plugin doesn't have any safeguards for non-string title fields.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Found a bug or have a feature request? Please open an issue on GitHub.
MIT © VeiaG



