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

payload-plugin-related-items

v1.0.5

Published

Related items plugin for Payload CMS

Readme

payload-plugin-related-items

A Payload CMS plugin that surfaces related content using classical, transparent similarity algorithms — no external AI service required.

Built for editorial sites, docs, knowledge bases, and any Payload project that wants "Related posts", "You might also like…", or "More like this" sections backed by predictable math instead of a black box.


Features

  • Deterministic ranking you can reason about and debug.
  • Four built-in scorers: Jaccard, Weighted Jaccard, Sørensen–Dice, BM25 (default).
  • Multi-field weighting, recency decay, and flexible exclusions.
  • In-memory LRU cache (TTL-aware) and optional precomputed sidecar collection for large corpora.
  • Admin sidebar widget with an editor-visible scorer dropdown plus optional adminField.scorer default (compare algorithms without touching collection config).
  • Keyword cloud rendered on the source-collection list view, lazy-loaded and computed on demand.
  • REST endpoint, typed getRelated() server API, and a headless useRelatedItems React hook.
  • Pairs with @payloadcms/plugin-search out of the box, or any custom data source via the SourceAdapter interface.

Install

pnpm add payload-plugin-related-items
# or
npm install payload-plugin-related-items

Requirements

  • Node.js: ^18.20.2 || >=20.9.0
  • Package manager: pnpm ^9 || ^10 (repo uses pnpm@10)
  • Payload: ^3.0.0 (peer dependency)

Dependency model

  • Required peer dependency
    • payload (^3.0.0)
  • Optional peer dependencies (needed only for UI integrations)
    • @payloadcms/ui (^3.0.0) for admin sidebar integration
    • react (^18 || ^19) and react-dom (^18 || ^19) for the client hook package (payload-plugin-related-items)

If you only use the server API (getRelated) and REST endpoint, optional UI peers are not required.

Quick start

The simplest setup pairs this plugin with @payloadcms/plugin-search, which handles keyword extraction and gives you one shared index to query.

// payload.config.ts
import { buildConfig } from 'payload'
import { searchPlugin } from '@payloadcms/plugin-search'
import { extractKeywords, payloadRelatedItems } from 'payload-plugin-related-items'

export default buildConfig({
  collections: [
    /* posts, articles, ... */
  ],
  plugins: [
    searchPlugin({
      collections: ['posts', 'articles'],
      searchOverrides: {
        fields: ({ defaultFields }) => [
          ...defaultFields,
          { name: 'keywords', type: 'text', hasMany: true },
        ],
      },
      beforeSync: ({ originalDoc, searchDoc }) => ({
        ...searchDoc,
        keywords: extractKeywords(
          [originalDoc.title, originalDoc.excerpt, originalDoc.body].filter(Boolean).join(' '),
        ),
      }),
    }),
    payloadRelatedItems({
      collections: {
        posts: {
          fields: [{ name: 'keywords', weight: 1 }],
          recency: { field: 'publishedAt', halfLifeDays: 60 },
        },
        articles: {
          fields: [{ name: 'keywords', weight: 1 }],
        },
      },
    }),
  ],
})

Plugin API compatibility

This package is authored with Payload's definePlugin API (recommended for published plugins) and preserves the standard consumer usage:

plugins: [
  payloadRelatedItems({
    collections: { posts: true },
  }),
]

For type-aware plugin registration, the package also augments payload's RegisteredPlugins map for 'payload-rplugin-elated-items', so users get typed plugin options when importing the package in TypeScript projects.

That's it. Each configured collection now exposes:

  • A sidebar Related Items panel in the admin.
  • GET /api/related/:collection/:id?limit=5 — JSON response.
  • getRelated({ payload, collection, id }) in server code.
  • useRelatedItems({ collection, id }) in client components (from payload-plugin-related-items).

Frontend: server component example

For Next.js App Router (or any RSC setup), call getRelated() in a server component and wrap it in <Suspense> so the page shell can stream while related items resolve.

// components/RelatedItemsWidgetBlock.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { getRelated } from 'payload-plugin-related-items'

export default async function RelatedItemsWidgetBlock({
  docId,
  collection,
  headline = 'Polecamy',
  limit = 4,
}: {
  docId: string | number
  collection: string
  headline?: string
  limit?: number
}) {
  const payload = await getPayload({ config })
  const items = await getRelated({ payload, collection, id: docId, limit, populate: 1 })

  if (!items.length) return null

  return (
    <section>
      <h2>{headline}</h2>
      <ul>
        {items.map((item) => (
          <li key={`${item.collection}:${item.id}`}>
            <a href={`/${item.collection}/${item.doc?.slug ?? item.id}`}>
              {item.doc?.title ?? item.id}
            </a>
          </li>
        ))}
      </ul>
    </section>
  )
}
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded-lg" />}>
  <RelatedItemsWidgetBlock docId={article.id} collection="articles" limit={4} />
</Suspense>

For client-side fetching (tabs, infinite scroll, scorer toggles), use useRelatedItems instead. See API → Populating original docs for populate / depth options.

Documentation

| Topic | What's in there | | ------------------------------------------ | ----------------------------------------------------------------------- | | Configuration | Full options reference: source, collections, cache, recency, disabling. | | Scorers | Algorithm comparison + the three layers where you can set the scorer. | | API | getRelated(), REST endpoint, headless React hook, result shape. | | Precomputation | Sidecar collection, incremental sync, full rebuilds. | | Word cloud | On-demand admin keyword cloud + REST endpoint. | | Source adapter | How keyword storage works + plugging in a non-search-plugin source. | | Development | Project layout, scripts, releasing, adding a scorer. |

Development scripts

Common repo scripts from package.json:

  • pnpm dev - runs the local Next.js dev app under dev/
  • pnpm dev:payload - runs Payload CLI with dev/payload.config.ts
  • pnpm dev:generate-types - regenerates Payload types for the dev app
  • pnpm dev:generate-importmap - regenerates the Payload import map
  • pnpm build - copies assets and builds JS + declaration files to dist/
  • pnpm lint - runs ESLint
  • pnpm test - runs integration tests (vitest) and e2e tests (playwright)

FAQ

Do I need @payloadcms/plugin-search?

No, but it's the path of least resistance. The plugin reads from any source via a SourceAdapter. The default adapter targets the search plugin's collection because (a) it already gives you a polymorphic relationship back to the originating doc, and (b) it's where most Payload projects already extract keywords. See Source adapter to roll your own.

Where do keywords actually get stored?

Once, on the search-plugin collection — not duplicated on each content collection. Your searchPlugin({ beforeSync }) decides what goes in. See Source adapter.

Which scorer should I use?

bm25 is a strong default. It down-weights generic words via IDF and length-normalizes, which matters once you have more than a few hundred documents. For very short keyword lists try dice; for sparse, tidy sets jaccard is fine. Full guidance in Scorers.

Can I use a different scorer for the admin widget vs. the public site?

Yes — that's a first-class concern. Set adminField.scorer as the initial scorer for the widget (collection default vs BM25 vs Dice, …). Editors can change it per session via the scorer dropdown in the sidebar. See Where to set the scorer.

What does the score number mean?

All blended scores are in [0, 1] — higher means more related. Exact BM25 keyword overlap normalizes to 1 for that query; partial overlaps are below 1. Recency decay (when configured) can multiply the blended score before filtering.

See Scorers.

Why does my sidebar show title/name/slug from search rows?

With the default search-plugin adapter, the plugin selects common display fields (title, name, slug, description) onto each source row so RelatedItem.source can render readable labels without calling populate. For populated originals use getRelated({ populate: true }) or ?populate=true as usual. See API.

Is the cache safe across multiple processes?

The LRU cache is per-process. Source-collection writes invalidate the local cache instantly via afterChange / afterDelete hooks. For multi-instance deployments where strong cross-process freshness matters, lower cache.ttlSeconds or disable the cache. For larger corpora, prefer the precomputed sidecar instead.

How fresh is the precomputed sidecar?

With precompute.incremental: true (default when precompute is enabled), the sidecar is updated on every source-collection write. Run rebuildRelatedIndex({ payload }) periodically if you want a belt-and-suspenders guarantee.

Does this work with Postgres / SQLite / Mongo?

Yes — the plugin only uses Payload's collection APIs (find, findByID, update, create, delete). Whatever Payload supports, this supports.

Does it support draft / locale-aware content?

Reads honour the requesting user's session via PayloadRequest, so collection-level access control applies. For drafts/locales specifically, pass an explicit filter in the collection config (e.g. { status: { equals: 'published' } }), or call getRelated({ filter, req }) per call.

How do I render the title / slug / cover image of related items on the frontend?

Pass populate: true (or { depth: 1 } for relationship resolution) to getRelated(), the REST endpoint (?populate=true), or the useRelatedItems hook. Each result then carries the full originating document as doc, batched server-side to avoid N+1 fetches. See API → Populating original docs.

What is the keyword cloud on the source-collection list page?

An admin-only helper that aggregates keyword frequencies across your source collection rows on demand and renders them sized by frequency. The renderer is code-split (React.lazy), so nothing is bundled or computed until an editor clicks Compute word cloud. See Word cloud for scale notes, configuration, and the REST endpoint that backs it.

Can I disable the admin widget without removing the plugin?

Yes — adminField: false (or adminField: { enabled: false }) keeps the REST endpoint, hooks, and getRelated() working without injecting any field into the admin.

Does this need a separate background worker?

No. The default path is in-memory + cache. Precomputation runs inline in the source collection's hooks (incremental) or as an explicit rebuildRelatedIndex() call you can schedule any way you like (cron, queue job, onInit, etc.). No new runtime to operate.

License

MIT.

Media

This plugin was tested and developed in relation to my upcoming fairytale builder app.

Word Cloud per collection Item keywords