Headless command palette (⌘K / Ctrl+K) for Next.js App Router & React 18/19. Keyboard-first, accessible, i18n-ready, Tailwind-friendly. A lightweight, dependency-free alternative to
cmdk— built for App Router, RSC, and SSR out of the box.
Live demo → devformatlab.com — press ⌘K on any page.
Repository → github.com/zzmgc4/next-cmdk
If you've ever tried to add a ⌘K menu (the Vercel / Linear / Raycast / Stripe Docs style search) to a Next.js App Router project, you've probably hit one of these:
cmdkworks great but its docs are React-router-first and you spend an evening wiring it into App Router +useRouter()+ i18n.- Building it yourself sounds easy until you handle keyboard navigation, scroll-into-view, body-scroll lock, focus restore,
/shortcut, SSR, and a11y. - Most tutorials are out of date for Next.js 14/15 with React Server Components.
next-cmdk is a single-file, zero-dependency command palette component extracted from DevFormatLab — a production developer tools site with multi-language routes and a real ⌘K palette. It is open-sourced so you don't have to write the same keyboard handling, filtering, focus management, and accessibility logic from scratch.
- ✅ Next.js 13 / 14 / 15 App Router (
"use client"already baked in) - ✅ React 18 & 19 — strict mode safe, no
useEffectleaks - ✅ TypeScript — full types, no
any - ✅ Tailwind CSS — styled out of the box, override every slot
- ✅ SSR / RSC — server-render safe, hydrates cleanly
- ✅ i18n — every label is a prop, drop-in for
next-intl/next-i18next - ✅ a11y —
role="dialog",aria-modal, full keyboard control - ✅ Zero runtime dependencies — just React peer
npm install next-cmdk
# or
pnpm add next-cmdk
# or
yarn add next-cmdkPrefer copy-paste? This component is intentionally one file (
src/CommandPalette.tsx, ~290 lines). Copy it directly into your project — no package needed.
// components/Palette.tsx
"use client";
import { CommandPalette, type CommandItem } from "next-cmdk";
import { useRouter } from "next/navigation";
const items: CommandItem[] = [
{
id: "json",
title: "JSON Formatter",
description: "Pretty-print and validate JSON",
group: "tools",
href: "/tools/json-formatter",
keywords: ["json", "format", "beautify"],
},
{
id: "yaml",
title: "YAML → JSON",
description: "Convert YAML to JSON",
group: "converters",
href: "/convert/yaml-to-json",
},
{
id: "docs",
title: "Documentation",
group: "pages",
href: "/docs",
},
];
export function Palette() {
const router = useRouter();
return (
<CommandPalette
items={items}
groups={[
{ id: "tools", label: "Tools" },
{ id: "converters", label: "Converters" },
{ id: "pages", label: "Pages" },
]}
onSelect={(item) => item.href && router.push(item.href)}
/>
);
}// app/layout.tsx
import { Palette } from "@/components/Palette";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<nav>
{/* … logo, links … */}
<Palette /> {/* renders trigger button + ⌘K listener */}
</nav>
{children}
</body>
</html>
);
}Users can now press ⌘K (Mac) / Ctrl K (Win/Linux) or / from any page to open the palette, type to filter, ↑↓ to navigate, ↵ to open.
Need to open the palette from a custom button or trigger it programmatically?
"use client";
import { useState } from "react";
import { CommandPalette } from "next-cmdk";
export function MyApp() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Search</button>
<CommandPalette
open={open}
onOpenChange={setOpen}
items={items}
hideTrigger // skip the default trigger button
/>
</>
);
}| Feature | Notes |
|---|---|
| Multi-token AND search | Typing yaml csv matches items containing both tokens, in any order |
| Stable grouping | Groups render in the order you declare; empty groups auto-hide |
| Keyboard-first | ↑↓ to navigate, ↵ to open, Esc to close, scroll-into-view |
| Global hotkeys | ⌘K / Ctrl+K toggle, / to open (GitHub-style) |
| Mouse + keyboard sync | Hovering syncs the active row, no flicker |
| a11y | role="dialog", aria-modal, aria-label, focus management |
| Body scroll lock | Background doesn't scroll while palette is open |
| SSR safe | All browser APIs guarded with typeof window/navigator |
| i18n-ready | Every label is a prop (labels.placeholder, labels.empty, …) |
| Theming | Override any slot via classNames.{trigger, panel, item, …} |
| Dark mode | Tailwind dark: variants included |
| Zero deps | Only react and react-dom as peers |
| Prop | Type | Default | Description |
|---|---|---|---|
items |
CommandItem[] |
required | Searchable list |
groups |
CommandGroup[] |
undefined |
Optional group definitions (controls render order) |
open |
boolean |
— | Controlled open state |
onOpenChange |
(open: boolean) => void |
— | Open-state setter |
onSelect |
(item: CommandItem) => void |
— | Pick handler; falls back to window.location.assign(item.href) |
shortcut |
"mod+k" | "mod+/" | "mod+j" | false |
"mod+k" |
Toggle hotkey, mod = ⌘ on Mac, Ctrl elsewhere |
slashToOpen |
boolean |
true |
Bind / to open (skipped when user is typing) |
labels |
CommandPaletteLabels |
English defaults | i18n strings |
classNames |
CommandPaletteClassNames |
— | Per-slot className overrides |
hideTrigger |
boolean |
false |
Don't render the built-in trigger button |
type CommandItem = {
id: string; // stable key
title: string; // first line
description?: string; // second line
group?: string; // must match a `groups[].id`
href?: string; // used as fallback if no onSelect
keywords?: string[]; // extra search synonyms
icon?: React.ReactNode; // leading icon
};Standalone hook if you want global hotkeys but bring your own UI:
import { useCommandPaletteShortcut } from "next-cmdk";
const [open, setOpen] = useState(false);
useCommandPaletteShortcut(setOpen, { combo: "mod+k", slashToOpen: true });const t = useTranslations("palette");
<CommandPalette
items={items}
labels={{
placeholder: t("placeholder"),
empty: t("empty"),
hint: t("hint"),
triggerLabel: t("triggerLabel"),
}}
/>items is just an array — fetch it however you like, then memoize:
const { data = [] } = useSWR("/api/search", fetcher);
const items = useMemo<CommandItem[]>(
() => data.map((d) => ({ id: d.slug, title: d.name, href: d.url })),
[data]
);Persist last-picked ids to localStorage, prepend them as a "recent" group at render time.
The component ships with default Tailwind classes. To use without Tailwind, pass your own classNames={{ panel: "my-panel", item: "my-item", … }} and write the CSS.
| next-cmdk | cmdk |
DIY | |
|---|---|---|---|
| Bundle size | ~3 KB gzip | ~5 KB gzip | varies |
| Dependencies | 0 | 1 (@radix-ui/react-dialog) |
varies |
| Next.js App Router | ✅ first-class | ✅ works | varies |
| Built-in trigger button | ✅ | ❌ | ❌ |
Built-in / & ⌘K hotkeys |
✅ | ❌ | ❌ |
| i18n prop API | ✅ | partial | varies |
| Single file (copy-paste) | ✅ ~290 LOC | ❌ multi-file | — |
| Tailwind out of the box | ✅ | ❌ unstyled | varies |
| Headless override | ✅ via classNames |
✅ | — |
Not a
cmdkkiller —cmdkis excellent and battle-tested.next-cmdkis a smaller, opinionated alternative if you want one file, zero deps, App-Router-shaped defaults.
- 📦 Minimal Next.js App Router →
examples/nextjs-app-router - 🌐 Production site → devformatlab.com — press ⌘K on any page
Modern evergreen browsers. Tested on Chrome 100+, Firefox 100+, Safari 15+, Edge 100+. No polyfills required.
- Fuzzy ranking (currently substring AND-match)
- Virtualized list for 1000+ items
- Optional Framer Motion animations
- React Native port (
next-cmdk-native) - Storybook + Chromatic visual regression
PRs welcome. See CONTRIBUTING.md.
Q: Why "next-cmdk" if you don't depend on Next.js?
A: It works in any React 18+ project, but the defaults (App Router conventions, "use client", SSR guards) are tuned for Next.js. Naming reflects the most common use case.
Q: Will Server Components work? A: The palette itself is a Client Component (it has to be — it listens to keystrokes). Import it from a Client wrapper and you can keep your tree mostly server-rendered.
Q: Does it lock me into Tailwind?
A: No. Override every slot via classNames. You can also fork the single file and replace classes wholesale — it's only ~290 lines.
Q: How do I add a "Recent" section?
A: Store last-picked ids in localStorage, build a recent group at render time, prepend it in your groups array.
Q: Can I use it in React Router / Remix / Astro?
A: Yes — pass an onSelect handler that uses your router. The component never imports next/navigation.
Built by DevFormatLab
DevFormatLab is a free, no-signup suite of developer utilities:
- 🧰 JSON Formatter & Validator
- 🔀 JSON ↔ YAML ↔ CSV Converters
- 🧪 Regex Tester
- 🔐 JWT Decoder
- ⏱️ Unix Timestamp Converter
- 🔡 Base64 Encode / Decode
- 🌐 URL Encode / Decode
- #️⃣ Hash Generator (MD5 / SHA-1 / SHA-256 / SHA-512)
Press ⌘K on devformatlab.com to see this palette in production.
If next-cmdk saved you an afternoon, a ⭐ on the repository or a link back to devformatlab.com means a lot.
MIT © DevFormatLab
Keywords: nextjs command palette, next.js ⌘K menu, react command palette typescript, cmdk alternative, ctrl+k menu react, app router command palette, tailwind command palette, headless command palette, raycast style menu react, linear style search react, accessible command menu, i18n command palette, fuzzy search react component, keyboard shortcut react hook, Next.js 14 search bar, Next.js 15 search bar.