payload-plugin-related-items
v1.0.5
Published
Related items plugin for Payload CMS
Maintainers
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.scorerdefault (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 headlessuseRelatedItemsReact hook. - Pairs with
@payloadcms/plugin-searchout of the box, or any custom data source via theSourceAdapterinterface.
Install
pnpm add payload-plugin-related-items
# or
npm install payload-plugin-related-itemsRequirements
- Node.js:
^18.20.2 || >=20.9.0 - Package manager:
pnpm ^9 || ^10(repo usespnpm@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 integrationreact(^18 || ^19) andreact-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 (frompayload-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 underdev/pnpm dev:payload- runs Payload CLI withdev/payload.config.tspnpm dev:generate-types- regenerates Payload types for the dev apppnpm dev:generate-importmap- regenerates the Payload import mappnpm build- copies assets and builds JS + declaration files todist/pnpm lint- runs ESLintpnpm 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.

