npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@foundrykit/search-plugin

v0.1.0

Published

A plugin for Payload CMS that provides a cached, grouped and ranked site search over the plugin-search index, with fuzzy matching, editor-controlled relevance and search analytics.

Readme

@foundrykit/search-plugin

Cached, grouped and ranked site search for Payload CMS, layered on top of @payloadcms/plugin-search.

That plugin keeps a search collection in sync with your content through afterChange / afterDelete hooks. Sync is the part of an index implementation that rots, so this plugin does not reimplement it — it owns the read side:

  • One index read per request, cached in memory, instead of one query per collection per keystroke
  • Fuzzy matching via Fuse.js, so typos, plurals and word order stop mattering
  • Ranking by field weight and collection weight, so a title hit beats an incidental description hit
  • Grouping with per-group caps, and an opt-in empty-query preset
  • Editor operability — reindex on demand, and a "what is searchable" report that gives reasons
  • Search analytics — top queries and, more usefully, the ones returning nothing

Why the read side is the interesting half

A naive site search queries each source collection live with a like substring where clause. That caps how good relevance can ever get: substring matching has no word boundaries (searching tuni matches anything containing those four letters anywhere), there is no ranking, and there is nothing to measure or rebuild. Every complaint arrives anecdotally and costs a developer a code read to diagnose.

Install

pnpm add @foundrykit/search-plugin

Usage

Install it after searchPlugin, since it reads the collection that one creates.

import { searchPlugin as payloadSearchPlugin } from '@payloadcms/plugin-search'
import { searchPlugin } from '@foundrykit/search-plugin'

export default buildConfig({
  plugins: [
    payloadSearchPlugin({
      collections: ['pages', 'posts', 'hotels'],
    }),

    searchPlugin({
      groups: [
        { slug: 'destinations', label: 'Destinations', limit: 3 },
        { slug: 'stays', label: 'Places to stay', limit: 4 },
        // Utility pages are worth finding by name, but not worth suggesting unprompted.
        { slug: 'pages', label: 'Pages', limit: 3, showWhenEmpty: false },
      ],

      collections: [
        { slug: 'regions', group: 'destinations', path: '/regions', weight: 3 },
        { slug: 'hotels', group: 'stays', path: '/hotels', icon: 'bed', weight: 2 },
        {
          slug: 'pages',
          group: 'pages',
          // Empty string, not undefined: these are served at the site root.
          path: '',
          excludeSlugs: ['home', 'gift-list'],
        },
      ],

      analytics: { retentionDays: 90 },
    }),
  ],
})

Endpoints

| Method | Path | Purpose | | --- | --- | --- | | GET | /api/site-search?q= | Grouped, ranked results. Empty q returns the preset. | | POST | /api/site-search/reindex | Rebuild the projection now. Returns counts. | | GET | /api/site-search/inspect | What is indexed, and why anything missing was skipped. | | POST | /api/site-search/track | Attribute a click to a query. Only when analytics are on. |

Change the base with routePrefix. reindex and inspect require an authenticated user unless you set requireAdminForOperations: false.

Options

| Option | Default | Notes | | --- | --- | --- | | collections | — | Required. Which collections are searchable, and how their results present. | | groups | — | Required. Presentation buckets, with caps and empty-state opt-in. | | cacheTTL | 60000 | Milliseconds to hold the projection. | | fields | title 1, slug 0.4, description 0.2 | Fields matched, with relative weights. | | fuzzy | { threshold: 0.35, distance: 100, minMatchCharLength: 3 } | Fuse tuning. | | decodeEntities | true | Decode HTML entities in titles. | | analytics | false | true, or { retentionDays, collectionSlug }. | | requireAdminForOperations | true | Guard reindex and inspect. | | routePrefix | /site-search | Base path for endpoints. | | disabled | false | Bypass the plugin entirely. |

A note on minMatchCharLength

The default of 3 is deliberate. Two-character fragments match too much to be useful, and that is the single most common source of "why is this unrelated thing in my results".

A note on the empty-query preset

presetResults orders by collection weight and then alphabetically. It is not popularity- or analytics-ranked, and deliberately does not pretend to be — if you want a curated order, set weights. Once analytics have accumulated, the top-queries report is the honest input for that decision.

decodeEntities

Content migrated out of a CMS that stored HTML-encoded text (Umbraco, typically) keeps entities in its titles. Rich-text renderers decode on output, so a page body reads correctly while every plain-text surface — search results, filter labels, map pins — shows a literal &. Decoding at projection time fixes all of them at once.

Client helpers

import { useSearchHotkey, useIsAppleDevice } from '@foundrykit/search-plugin/client'

useSearchHotkey(open) // Cmd+K / Ctrl+K

The hotkey is suppressed inside inputs, textareas, selects and contenteditable. That guard matters: Ctrl+K is Lexical's "insert link" binding, and Lexical is Payload's own editor.

Analytics and privacy

Nothing identifying is stored — no IP, no session, no user relationship. Queries are behavioural data, and aggregate counts plus the zero-result list are what actually inform relevance work. Events past retentionDays are pruned on write, so the log stays bounded without needing a scheduled job the host project may not have.

Dashboard

import { SearchDashboard } from '@foundrykit/search-plugin/rsc'

A server component showing index counts, skipped documents with reasons, zero-result queries and top queries. Wire it into admin.components.views or a custom dashboard.

Development

pnpm install
pnpm test:unit
pnpm build
pnpm lint