krzysztofradomski

payload related items

by krzysztofradomski
Search
v3
^3.0.0
NOASSERTION

Overview

Summary generated from the README

Surfaces related posts or articles for Payload collections using deterministic similarity algorithms instead of an external AI service. It reads keywords from the Payload search plugin collection or a custom source adapter. It adds an admin sidebar panel, a REST endpoint, a server getRelated function and a React hook. Requires Payload 3.

Features

  • Four scorers: BM25 (default), Jaccard, Weighted Jaccard and Sørensen–Dice
  • Multi-field weighting, recency decay and configurable exclusions
  • In-memory TTL LRU cache plus optional precomputed sidecar collection
  • Admin sidebar widget with an editor-selectable scorer dropdown
  • Keyword cloud on the collection list view, computed on demand
  • REST endpoint, typed getRelated server API and useRelatedItems hook

Use cases

  • Show a Related posts section beneath articles on an editorial site
  • Add More like this suggestions to a documentation or knowledge base
  • Compare similarity algorithms on real content from the admin sidebar

From the author

Related items plugin for Payload CMS

Installation

npm i payload-plugin-related-items

3

Stars

0

Forks

2

Open Issues

2 months ago

Last Updated

24

Weekly Downloads

v1.0.5

npm Version

423.8 KB

Unpacked Size

0

Dependencies

GitHub stars

Trends appear after 7 days of data. Collecting since Oct 22 — 1 daily snapshot so far.

npm weekly downloads

Trends appear after 7 days of data. Collecting since Oct 22 — 1 daily snapshot so far.

Each point is npm's rolling 7-day download count.

Health Score

48/100Fair

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1220131707-root.svg)](https://payloaddirectory.dev/plugins/1220131707-root)
HTML
<a href="https://payloaddirectory.dev/plugins/1220131707-root"><img src="https://payloaddirectory.dev/badge/1220131707-root.svg" alt="Payload Directory"></a>
Updated Jul 25, 2026Created Apr 24, 2026Published May 19, 2026

README

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

TopicWhat's in there
ConfigurationFull options reference: source, collections, cache, recency, disabling.
ScorersAlgorithm comparison + the three layers where you can set the scorer.
APIgetRelated(), REST endpoint, headless React hook, result shape.
PrecomputationSidecar collection, incremental sync, full rebuilds.
Word cloudOn-demand admin keyword cloud + REST endpoint.
Source adapterHow keyword storage works + plugging in a non-search-plugin source.
DevelopmentProject 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

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.

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

Similar plugins