Skip to content

Repository files navigation

next-cmdk

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.

npm license size zero deps types

Live demo → devformatlab.com — press ⌘K on any page.
Repository → github.com/zzmgc4/next-cmdk


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

  • cmdk works 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.

Built specifically for

  • Next.js 13 / 14 / 15 App Router ("use client" already baked in)
  • React 18 & 19 — strict mode safe, no useEffect leaks
  • 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
  • a11yrole="dialog", aria-modal, full keyboard control
  • Zero runtime dependencies — just React peer

Install

npm install next-cmdk
# or
pnpm add next-cmdk
# or
yarn add next-cmdk

Prefer copy-paste? This component is intentionally one file (src/CommandPalette.tsx, ~290 lines). Copy it directly into your project — no package needed.


Quick start (Next.js App Router)

1. Create a client component that wraps the palette

// 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)}
    />
  );
}

2. Drop it anywhere in your layout

// 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>
  );
}

3. That's it.

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.


Controlled mode

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
      />
    </>
  );
}

Features

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

API

<CommandPalette /> props

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

CommandItem shape

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
};

useCommandPaletteShortcut(setOpen, options)

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 });

Recipes

i18n with next-intl

const t = useTranslations("palette");
<CommandPalette
  items={items}
  labels={{
    placeholder: t("placeholder"),
    empty: t("empty"),
    hint: t("hint"),
    triggerLabel: t("triggerLabel"),
  }}
/>

Async / remote search

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]
);

Recently used

Persist last-picked ids to localStorage, prepend them as a "recent" group at render time.

Plain JS / no Tailwind

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.


Comparison

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 cmdk killercmdk is excellent and battle-tested. next-cmdk is a smaller, opinionated alternative if you want one file, zero deps, App-Router-shaped defaults.


Examples


Browser support

Modern evergreen browsers. Tested on Chrome 100+, Firefox 100+, Safari 15+, Edge 100+. No polyfills required.


Roadmap

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


FAQ

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:

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.


License

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.

About

Headless ⌘K command palette for Next.js App Router. Keyboard-first, accessible, i18n-ready, Tailwind-friendly. cmdk alternative.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages