@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
mediaupload collection and ausersauth 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]
editoris 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 ownreadaccess restrictive is still worthwhile —@composius/payload-plugin-authdefaults 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 controlThe control adds
@composius/payload-plugin-articles/client#EditorFontSizeFeatureClientto the admin panel: runpayload generate:importmapafter 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#EditorLinkEmphasisFeatureClientinto the admin panel: runpayload generate:importmapafter 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#BlocksFeatureClientand the title field component into the admin panel: runpayload generate:importmapafter 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 reactnext (^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.
