D
P
0

Next.js

Cmd+K Search Returns "No matches" for Almost Every Keyword in Prod? The Index Was Built From Placeholders

July 11, 2026·5 min read
Cmd+K Search Returns "No matches" for Almost Every Keyword in Prod? The Index Was Built From Placeholders

On a client's crypto news site I was helping tidy up, there was a Cmd+K command palette to search everything: articles, coin names, glossary terms, authors. A nice feature, and in the local demo it worked. A few weeks after launch, the client reported that search was "coming up empty." I opened production, hit Cmd+K, typed cardano, and got back a single cold line: "No matches."

Odd, because the site had hundreds of real articles and thousands of glossary terms. cardano is not an obscure keyword; it is one of the most common coins around. I tried bitcoin — got results. Tried ethereum — got results. Tried solana, cardano, polkadot, an author name, article titles I knew existed — all "No matches." The pattern was strange: a small handful of keywords worked, and the rest, which was the vast majority, returned nothing at all.

The symptom

The confusing part was that nothing looked broken. No console errors, no failed requests in the Network tab, no red screen. The command palette opened smoothly, the animation played, and for a few keywords it genuinely returned correct results. If I only did a quick test with bitcoin, I would conclude the feature was healthy and move on. That is exactly what happened during the demo, and exactly why this reached production.

My first instinct was a bad one again: I assumed a stale index that had not rebuilt, or CMS data that had not synced to production. I checked the CMS — every article was there. I checked the content endpoint — it returned in full. The data was clearly alive. Yet search stayed blind to almost all of it.

Tracing it

Since this search runs on the client, I opened the component that builds the index, CommandPalette.tsx. Inside was a buildIndex() function that assembled the list of searchable items. I traced where it pulled its sources from, and that is where it clicked. Every source in buildIndex() pointed at a single module: lib/placeholder-data.ts.

// CommandPalette.tsx
import { coins, articles, nfts } from "@/lib/placeholder-data";
 
function buildIndex() {
  return [
    ...coins.map(toSearchEntry),
    ...articles.map(toSearchEntry),
    ...nfts.map(toSearchEntry),
  ];
}

I opened lib/placeholder-data.ts, and it was exactly what the name promised: dummy data from the mockup phase. Six real coins — bitcoin, ethereum, bnb, xrp, usdt, solana — with made-up ids, a few placeholder articles titled "Lorem ipsum," and a hardcoded NFT list. Not one line touched the CMS.

At that point the pattern made complete sense. The keywords that "worked" — bitcoin, ethereum, solana — were the coins that happened to live in those six dummies. cardano was not in the dummy list, so it was "No matches." Search had never been blind to the real data. It had never known the real data existed.

The root cause

This is the most deceptive kind of bug because it never fails loudly. buildIndex() assembled 100% of the index from a mockup-phase static module and never once queried the CMS. No type error, because placeholder-data.ts had the exact same shape as the real data. No crash, because the data was valid — just fake. And because a handful of real coins happened to be baked into those dummies, casual testing always passed.

So the feature shipped serving fakes. Not because someone typed the wrong thing, but because the connection to the real source was never wired up. The placeholder that was only ever meant as a temporary stand-in during the mockup never got pulled out, and instead became the sole source for a live surface.

The fix

The fix is to build the index from real content. I added a cached /api/search-index route that calls getSearchIndex(), which sweeps the real articles, coins, glossary, and authors, and the component fetches it once per session and filters client-side.

// app/api/search-index/route.ts
import { getSearchIndex } from "@/lib/search";
 
export const revalidate = 3600;
 
export async function GET() {
  const index = await getSearchIndex(); // real articles + coins + glossary + authors
  return Response.json(index);
}
// CommandPalette.tsx
const [index, setIndex] = useState<SearchEntry[]>([]);
 
useEffect(() => {
  fetch("/api/search-index")
    .then((r) => r.json())
    .then(setIndex);
}, []);
 
const results = index.filter((e) =>
  e.title.toLowerCase().includes(query.toLowerCase())
);

Now cardano returns results, and so does every real coin, article, term, and author. Search finally sees the actual site.

After that I ran a full audit, because if one surface was quietly fed placeholders, others might be too. I grepped every component for imports of placeholder-data:

grep -rl 'placeholder-data' --include=*.tsx components/

For each hit I checked one thing: is this module the primary source of a live surface, or only a fallback? Placeholder data is fine, but only as a typed empty-result fallback — say when a fetch fails — never as the sole source of a feature that ships to users.

The takeaway

Bugs that fail loudly are easy. Bugs that succeed quietly while serving fake data are the dangerous ones, because the demo passes, the console is clean, and nobody is suspicious until a real user types a keyword that does not happen to live in the dummy set. If you have a live surface — search, a listing, a feed — make sure its source is truly the CMS, not a mockup module someone forgot to remove. And before you conclude a feature is healthy, test it with a keyword you know exists in the real data but could not possibly exist in the dummies. Since then, whenever a feature "works" only for the examples I picked myself, I stop and ask: is this reading real data, or just echoing what I typed?