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

@composius/payload-plugin-articles

v1.12.1

Published

Payload plugin that adds an Articles collection

Readme

@composius/payload-plugin-articles

A Payload CMS plugin that adds an articles collection with drafts (autosave), live preview, and SEO fields from @payloadcms/plugin-seo, plus a nestable categories collection (breadcrumbs from @payloadcms/plugin-nested-docs) for organizing articles, and an opt-in authors collection for attributing them.

Collections

articles

| Field | Type | Notes | | ------------- | -------------- | ----------------------------------------- | | title | text | required, used as admin title | | slug | text | auto-generated from title, unique | | category | relationship | relates to categories, rendered as a checkbox tree; falls back to the default category | | editor | relationship | relates to users; defaults to the creating user, editable afterwards | | author | relationship | only with authors: true, relates to authors | | coverImage | upload | relates to media | | content | richText | | | publishedAt | date | auto-set on first publish | | meta | group | SEO title/description/image/preview |

Requires a media upload collection and a users auth collection in the host config.

The editor defaults to the user who creates the article (via a beforeChange field hook) but can be reassigned to any existing user at any time. Point it at a different users collection with the usersSlug option. In the articles list, the editor column resolves the user's name, then the users collection's title field (useAsTitle), then their email. This pairs with @composius/payload-plugin-auth, whose users collection has a required name and useAsTitle: 'name'.

[!NOTE] editor is a relationship from a collection that is publicly readable by default (published articles) into your auth collection. Payload 3.90.0 closed two ways a relationship like that could be used to learn about documents the caller cannot read (GHSA-7c34-32v3-j575, GHSA-fpww-c55p-cjv6), which is why this plugin requires ^3.90.1. Keeping the users collection's own read access restrictive is still worthwhile — @composius/payload-plugin-auth defaults it to authenticated users.

authors

Opt-in — enable it with the authors: true option. When disabled (the default), neither the collection nor the author field on articles is registered, and articles are attributed through editor alone.

| Field | Type | Notes | | ------------- | ---------- | ------------------------------------------------------------ | | name | text | required, used as admin title | | picture | upload | optional, relates to media | | contact | text | optional; email, website, or any other contact detail | | biography | textarea | optional |

When no picture is set, the admin sidebar previews a deterministic boring-avatars "beam" avatar generated from the author name. A front-end can reproduce the same avatar from the name with the boring-avatars <Avatar variant="beam" /> component.

categories

| Field | Type | Notes | | ------------- | -------------- | -------------------------------------------------- | | name | text | required, used as admin title | | slug | text | auto-generated from name, unique | | parent | relationship | relates to categories (nested categories) | | description | textarea | | | isDefault | checkbox | at most one category at a time — see below | | breadcrumbs | array | read-only, populated by plugin-nested-docs hooks |

On articles, category is rendered by a custom sidebar component (CategoryFieldClient from the /client export): a checkbox per category, with children indented under their parent. Selection is exclusive — checking a category unchecks the previous one, and checking it again clears it.

The default category

Ticking Default on a category clears the flag on whichever category held it before, through an afterChange hook: only one category is the default at any time, whether it is set from the admin panel, the REST API or a seed script.

Articles saved without a category are given that default. The box is already ticked when a new article's form opens (a defaultValue on the category field), and a beforeChange field hook re-applies it to any save that reaches the server with an empty category — an editor who unticked it, an import, a REST call. An article always keeps a category it was explicitly given, and with no category flagged as the default, nothing is filled in.

Pass useDefaultCategory: false to keep the checkbox (it stays part of the schema, and of whatever a front end reads) without articles ever falling back to it.

Categories are nestable: pick a parent and @payloadcms/plugin-nested-docs keeps breadcrumbs (doc, label, url) up to date on save, including on all descendants. The parent picker excludes the category itself and its descendants.

Editor font size

The content editor's toolbar ends with a font size control — Small, Normal, Large, Huge — that scales the text being written, so a long article is readable without leaning into the screen.

It is CSS over Payload's own sizes and nothing else. No size is stored on any node, nothing is added to the saved rich text, and a front end renders the article exactly as it did before the control existed. Normal is the size Payload's lexical editor already draws at.

The choice is remembered in the browser, and applies to every editor this plugin puts on the page. editorFontSize sets the size an editor opens at before anyone has picked one, and false leaves the control out entirely:

ComposiusPayloadPluginArticles({ editorFontSize: 'large' }) // opens large
ComposiusPayloadPluginArticles({ editorFontSize: false })   // no control

The control adds @composius/payload-plugin-articles/client#EditorFontSizeFeatureClient to the admin panel: run payload generate:importmap after upgrading.

Emphasized links

Payload draws links in the editor green, under a dotted border, which is easy to miss in a wall of prose. emphasizeEditorLinks draws them blue and continuously underlined instead — more contrast against the surrounding text:

ComposiusPayloadPluginArticles({ emphasizeEditorLinks: true })

Off by default. Like the font size, it is CSS over the admin panel and nothing more: no node carries the emphasis, so a front end styles its links however it already did. The blue is the plugin's own — Payload's palette has no blue to borrow — and follows the admin theme, darker on light and lighter on dark.

The styling is scoped to this plugin's editor, so a site running the pages plugin alongside can have one emphasized and the other left alone.

With the option on, the editor pulls @composius/payload-plugin-articles/client#EditorLinkEmphasisFeatureClient into the admin panel: run payload generate:importmap after turning it on.

Video embeds

The content editor carries a Video block: paste the link of a YouTube, Vimeo or Gan Jing World video and that is the whole of it. A link of any other kind is refused with a message in the field, in English or in French.

| Provider | Links it reads | Player URL | | -------------- | ----------------------------------------------------------------------------- | ------------------------------------- | | YouTube | watch?v=, youtu.be/, /embed/, /shorts/, /live/, youtube-nocookie.com | youtube.com/embed/<id> | | Vimeo | vimeo.com/<id>, channel, group and album links, unlisted /<id>/<hash> | player.vimeo.com/video/<id> | | Gan Jing World | ganjingworld.com/video/<id> (or ganjing.com), with or without a locale | ganjingworld.com/embed/<id> |

The title

Alongside the link the block carries a read-only title, fetched from the provider while the document saves — YouTube and Vimeo through their oEmbed endpoints, Gan Jing World (which publishes none) from the og:title of the video page. No API keys, no configuration.

The field fills itself in as the link is typed, before any save: all three providers allow cross-origin reads, so the admin panel asks them directly. The save then resolves the link again server-side and that pass is the authoritative one — a title arriving from an API client is refetched, never trusted.

A saved link is looked up once: the save reuses whatever the previous one resolved, so autosave does not hammer the providers, and only editing the link asks again. A lookup that comes back empty is remembered as such — a deleted or private video is not re-fetched on every keystroke — and the field says so rather than leaving the editor to guess:

Title ⌷ (empty) The provider returned no title for this link

A link that is not a video link of a supported provider is called out in the same place, as soon as it is typed — the url field's own validation only speaks up once the document is submitted:

Title ⌷ (empty) No title: this is not a YouTube, Vimeo or Gan Jing World video link

The title never blocks a save: a provider that is down, slow (there is a 5s timeout) or unreachable costs the label, nothing more. It is a convenience for editors scanning a document, not something a front end should depend on — it is a snapshot of the title as of the last save, and the block's link remains the source of truth.

Rendering

Only the link matters for playback, so a document never holds a player URL that has since moved. Turn it into one at render time with parseVideoEmbedUrl, which returns { embedUrl, id, provider } or null:

import { parseVideoEmbedUrl } from '@composius/payload-plugin-articles'

const VideoEmbed = ({ url }: { url: string }) => {
  const video = parseVideoEmbedUrl(url)

  return video ? (
    <iframe allowFullScreen src={video.embedUrl} title="Video" />
  ) : null
}

The block's slug is exported as VIDEO_EMBED_BLOCK_SLUG (videoEmbed), to match against blockType while walking the rich text. Its fields are url, title (which may be absent) and titleUnavailable.

The block pulls @payloadcms/richtext-lexical/client#BlocksFeatureClient and the title field component into the admin panel: run payload generate:importmap after upgrading.

Cache revalidation

Publishing, unpublishing or deleting a document invalidates the Next.js cache tags of all three collections, so a front end built on cacheComponents picks the change up. Tag a 'use cache' function with the matching tag and the admin panel does the rest:

// app/articles/[slug]/page.tsx
import { cacheTag } from 'next/cache'
import { articleTag, ARTICLES_TAG } from '@composius/payload-plugin-articles/tags'
import { getPayload } from 'payload'
import config from '@payload-config'

const getArticle = async (slug: string) => {
  'use cache'
  cacheTag(articleTag(slug))

  const payload = await getPayload({ config })
  const { docs } = await payload.find({
    collection: 'articles',
    where: { slug: { equals: slug } },
  })
  return docs[0]
}

The tags, all exported from @composius/payload-plugin-articles/tags — an entry point that imports neither payload nor next:

| Tag | Covers | | ---------------------- | ------------------------------------------------------- | | ARTICLES_TAG | every article — listings, archives, feeds, sitemaps | | articleTag(slug) | one article, the way a /articles/[slug] route does | | articleIdTag(id) | one article, by id | | CATEGORIES_TAG | every category | | categoryTag(slug) | one category, by slug | | categoryIdTag(id) | one category, by id | | AUTHORS_TAG | every author (only with authors: true) | | authorIdTag(id) | one author, by id — authors have no slug |

A save invalidates the collection tag and both tags of the document, plus the former slug when a document is renamed — a page that changes address leaves a cache entry behind at the old one.

Articles carry the name of their category and author, so saving one of those invalidates ARTICLES_TAG too. The reverse is not true, and does not need to be: a page listing the articles of a category claims both tags itself.

const getCategoryArticles = async (slug: string) => {
  'use cache'
  // The category's own data, and the articles inside it.
  cacheTag(categoryTag(slug), ARTICLES_TAG)
  // ...
}

Autosaved drafts are skipped: nothing about them is public. The hooks run when an article is published, when a published article is saved again, and when one is unpublished or deleted. Categories and authors have no drafts, so every save counts.

By default the tags expire at once, so the first visitor after a save is served a fresh page. Pass revalidate: { profile: 'max' } for stale-while-revalidate instead: nobody waits, but the visitor right after a save — usually the editor checking their own work — sees the previous version.

Revalidation is a no-op wherever Next.js is not running (a migration, a seeding script, a test run), and never fails a write. To skip it for one operation — a bulk import, say — set context.disableRevalidate:

await payload.update({
  collection: 'articles',
  id,
  data,
  context: { disableRevalidate: true },
})

Requirements

The following dependencies are required to be installed in your project before using this plugin:

  • @payloadcms/plugin-nested-docs (^3.90.1)
  • @payloadcms/plugin-seo (^3.90.1)
  • @payloadcms/richtext-lexical (^3.90.1)
  • @payloadcms/ui (^3.90.1)
  • payload (^3.90.1)
  • react (^19.0.0)
pnpm add @payloadcms/plugin-nested-docs @payloadcms/plugin-seo @payloadcms/richtext-lexical @payloadcms/ui payload react

next (^16.3.3) is an optional peer dependency: it is only needed for cache revalidation, and any Payload app already running inside Next.js has it.

Usage

import { buildConfig } from 'payload'
import { ComposiusPayloadPluginArticles } from '@composius/payload-plugin-articles'

export default buildConfig({
  plugins: [ComposiusPayloadPluginArticles()],
  // ...
})

Options

All optional — defaults shown as comments:

ComposiusPayloadPluginArticles({
  // Articles access per operation. Defaults: read = published or authenticated,
  // create/update/delete = authenticated.
  access: { read, create, update, delete },

  // Categories access per operation. Defaults: read = anyone,
  // create/update/delete = authenticated.
  categoriesAccess: { read, create, update, delete },

  // Authors collection + `author` field on articles (default: false).
  authors: false,

  // Authors access per operation, when `authors` is enabled.
  // Defaults: read = anyone, create/update/delete = authenticated.
  authorsAccess: { read, create, update, delete },

  // Give articles saved without a category the one flagged `Default`
  // (default: true). False keeps the checkbox but never applies it.
  useDefaultCategory: true,

  // Users collection the article `editor` field relates to. Default: 'users'.
  usersSlug: 'users',

  // Field-level access controlling who may change an article's `editor`.
  // Default: any authenticated user.
  editorUpdateAccess: ({ req: { user } }) => Boolean(user),

  // Size the content editor opens at, and whether the toolbar control is there
  // at all: 'small' | 'normal' (default) | 'large' | 'huge', or false.
  editorFontSize: 'normal',

  // Draw the content editor's links blue and continuously underlined, rather
  // than Payload's green under a dotted border (default: false).
  emphasizeEditorLinks: false,

  // Front-end URL of an article, used for (live) preview and SEO.
  // Default: `${NEXT_PUBLIC_SERVER_URL || SERVER_URL}/articles/${slug}`
  articleUrl: (slug) => string,

  // SEO meta group + generate endpoints. `true` (default) uses built-in
  // generate functions; pass an object to override any of them; `false` disables.
  // `siteName` ends every generated title with it, as `Title | Site name`.
  seo: { generateTitle, generateDescription, generateImage, generateURL, siteName: 'Acme' },

  // Next.js cache invalidation on save and delete, for all three collections
  // (default: enabled). Pass false to drop the hooks entirely.
  revalidate: {
    // revalidateTag's second argument: a cacheLife profile name, or an
    // inline { expire } in seconds (default: { expire: 0 }).
    profile: { expire: 0 },

    // Extra tags to invalidate alongside the built-in ones. `collection` tells
    // the three collections apart.
    tags: ({ collection, doc, operation, previousDoc }) => ['sitemap'],

    // Called instead of the default debug log when a revalidation fails.
    onError: (error, event) => {},
  },

  // Keeps the collection schema but disables runtime behavior (default: false).
  disabled: false,
})

Development

From the monorepo root:

pnpm install
pnpm dev:articles                                        # dev Payload app with this plugin
pnpm vitest run packages/payload-plugin-articles/test    # unit tests
pnpm vitest run dev/configs/articles                     # integration tests
pnpm test:e2e                                            # e2e tests (playwright)
pnpm --filter @composius/payload-plugin-articles build  # build to dist/

See the root README for the release flow.