TL;DR Use cmdk inside a Radix dialog, mounted once in the root layout. Listen for Cmd or Ctrl plus K and call preventDefault, add a hidden Dialog.Title, hand focus back when the dialog closes, reset the query on every close, and mark server results forceMount so cmdk doesn't filter them away.
A command palette in React is a dialog with a search box and a keyboard-driven list, opened with Cmd+K on a Mac and Ctrl+K elsewhere. The quickest solid way to build one in Next.js is cmdk for the list and filtering, inside a Radix dialog for focus trapping and Escape. That's the same pair shadcn/ui's Command component uses. The parts people get wrong are small: the browser's own Ctrl+K, a dialog with no title, focus that doesn't come back, and server results that cmdk filters away.
I built qodesign's Command-K Dashboard, and the version below is what I'd drop into any App Router app. It ran on a fresh Next.js 16.4 app with React 19.3, cmdk 1.1, @radix-ui/react-dialog 1.3 and Tailwind CSS v4, and I drove it in headless Chromium after next build and next start.
cmdk ships its own Command.Dialog, which wraps the same Radix dialog. I use Radix directly so the title, the focus handling and the classes all sit in one place where you can see them.
Command-K Dashboard running live. A whole SaaS app you drive from ⌘K: rows stretch into pages, coins fly into the total.
e.preventDefault() matters on Windows and Linux, where Ctrl+K belongs to the browser's search bar. Checking metaKey || ctrlKey covers both platforms with one listener. In the test, Ctrl+K and Meta+K both opened the palette, pressing the shortcut again closed it, and it also opened while focus was inside a text field on the page. If you'd rather not steal the shortcut from inputs, return early when e.target is an input or textarea.
Radix expects a Dialog.Title inside the content, because that's what names the dialog for screen readers. A palette has no visible heading, so the title and description are there with sr-only. In the test, the dialog's aria-labelledby pointed at the "Command menu" title. The label prop on Command is a different thing: it labels the search list, not the dialog. shadcn's CommandDialog does the same trick with a hidden DialogTitle and DialogDescription. I read its registry source to check.
Without a Dialog.Trigger, Radix has nothing to return focus to when the palette closes. In my first run, focus didn't go back to the text field after Escape, so a user typing there had to click back into it. The fix is to remember document.activeElement when the shortcut fires and focus it again in onCloseAutoFocus. After the fix, Escape put focus back in the field the user came from. The isConnected check skips this after a navigation, when the old element is gone.
The first version reset the search text in onOpenChange only. Radix calls that for Escape and clicks outside, but not when my own code closed the dialog after a selection, so the old query was still in the box the next time it opened. Routing every close through changeOpen fixed it: after picking a result and reopening, the input was empty.
cmdk filters items on the client by their value and keywords. That works for the static pages: typing "acct" matched both Settings (keyword "account") and Billing, because cmdk's matching is fuzzy. But server results are already filtered by your API, and cmdk may hide some of them when its own matching disagrees. forceMount on those items tells cmdk to always show them. cmdk also tracks each item by its value, so keep values unique. Prefixing them per group, like page ${p.label} and doc ${d.slug}, keeps a page and a doc with the same name apart.
The search waits 200 ms after the last keystroke, and the effect's cleanup aborts the previous request, so a slow answer for "bi" can't land on top of the answer for "bil". In the test, typing "bil" showed Billing at once and added "Billing FAQ" from the server a moment later (the 200 ms wait plus the endpoint's fake 300 ms delay). Arrow Down moved the highlight to it and Enter went to /docs/billing-faq.
cmdk renders with no styles. The highlighted item carries data-selected="true", so Tailwind's data-[selected=true]:bg-zinc-100 styles it without any state of your own. Group headings get a cmdk-group-heading attribute if you want to style those too.
If you already use shadcn/ui, yes, add its Command component and keep the shortcut, focus and forceMount parts from above. It's the same cmdk and Radix underneath with your theme already applied. If you don't use shadcn, the component above is about 140 lines with two small dependencies, and nothing is hidden in a wrapper. I didn't run the shadcn install for this post.
Add one keydown listener on document that checks e.key for "k" and e.metaKey || e.ctrlKey, calls e.preventDefault() and toggles the open state. Mount the component once in your root layout so it works on every page.
cmdk is an unstyled React component for command menus. It handles the search input, filtering, keyboard navigation, groups and empty states. shadcn/ui's Command component is built on it.
cmdk runs its own filter over every item. If your server already filtered the results, add forceMount to those items, or set shouldFilter={false} on Command when every item comes from the server.