@composius/payload-plugin-pages
v2.4.2
Published
Payload plugin that adds a Pages collection
Readme
@composius/payload-plugin-pages
A Payload CMS plugin that adds a pages collection with drafts (autosave), live preview, and SEO fields from @payloadcms/plugin-seo.
Fields
| Field | Type | Notes |
| ------------- | ---------- | ----------------------------------------- |
| title | text | required, used as admin title |
| slug | text | auto-generated from title, unique |
| coverImage | upload | relates to media |
| content | richText | only with content: 'field' (see below) |
| layout | blocks | the content block, plus any you pass |
| publishedAt | date | auto-set on first publish |
| meta | group | SEO title/description/image/preview |
Requires a
mediaupload collection in the host config.
Blocks
A page is written in its layout field. Out of the box that field holds one
block — the rich text one, contributed by the plugin — and yours join it:
ComposiusPayloadPluginPages({ blocks: [Hero, CallToAction] })
// layout: content, hero, callToActionBlocks registered on the config are named by slug instead, so one definition is shared by every field that uses it rather than copied into each:
export default buildConfig({
blocks: [Hero],
plugins: [ComposiusPayloadPluginPages({ blockReferences: ['hero'] })],
})blockReferences also takes block objects, and the two options combine — pass
blocks alongside blockReferences and the inline blocks join the references
on the same field, since Payload allows a blocks field only one of the two
lists.
Prose
Rich text is a block like any other, and the plugin adds it for you — the
content block, the same lexical editor and toolbar features the standalone
field used. The content option says where prose lives:
| Value | Effect |
| ------------------ | ------------------------------------------------------------- |
| 'block' (default) | a content block, added to the layout |
| 'field' | a fixed content richText field on the document |
| false | neither — the layout is exactly what you pass |
Define a block of your own under the content slug and it replaces the built-in
one instead of colliding with it, inline or by reference:
ComposiusPayloadPluginPages({ blocks: [{ slug: 'content', fields: [...] }] })It comes with its own thumbnail for the block drawer — a heading over three
lines of prose, inlined as an SVG data URI, so there is no asset for the host to
serve. (An admin panel behind a strict CSP needs img-src data:.)
contentBlock() is exported for hosts that want to place it themselves — first
in the picker, registered in config.blocks, wrapped in a tab. It is a factory,
not a shared object: Payload marks a block sanitized in place, so each config
needs its own copy.
import { contentBlock } from '@composius/payload-plugin-pages'
export default buildConfig({
blocks: [contentBlock(), Hero],
plugins: [ComposiusPayloadPluginPages({ blockReferences: ['hero', 'content'] })],
})The block and the field store their text in different places — rows in a
pages_blocks_content table, versus a column on pages — so moving between
them on a populated collection needs a migration that copies the values across.
Nothing is migrated automatically, and a dropped field takes its column with it.
The default meta description reads whichever is present: the content field
when the collection has one, otherwise the first content block in the layout.
Editor font size
The editor toolbar ends with a font size control — Small, Normal, Large, Huge — that scales the text being written, so a long page 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
document 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:
ComposiusPayloadPluginPages({ editorFontSize: 'large' }) // opens large
ComposiusPayloadPluginPages({ editorFontSize: false }) // no controlThe option reaches the built-in content block and the content field alike. A
content block you place yourself takes it directly:
import { contentBlock } from '@composius/payload-plugin-pages'
contentBlock({ fontSize: 'large' })The control adds
@composius/payload-plugin-pages/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:
ComposiusPayloadPluginPages({ 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.
It reaches the built-in content block and the content field alike, and a
content block you place yourself takes it directly:
contentBlock({ emphasizeLinks: true })The styling is scoped to this plugin's editor, so a site running the articles plugin alongside can have one emphasized and the other left alone.
With the option on, the editor pulls
@composius/payload-plugin-pages/client#EditorLinkEmphasisFeatureClientinto the admin panel: runpayload generate:importmapafter turning it on.
Video embeds
The rich text editor — the content block as well as the content field —
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-pages'
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. It is a lexical block, not
a layout one: it lives inside the prose, not in the layout field.
The block pulls
@payloadcms/richtext-lexical/client#BlocksFeatureClientand the title field component into the admin panel: runpayload generate:importmapafter upgrading.
SEO
The generate buttons in the meta group fill the fields from the page: the
title from title, the description from the start of content (or of the
first content block of the layout), the image from coverImage. Replace any of
them through the seo option.
siteName only ever goes after the title. A custom generateTitle that puts
it first instead, and uses it alone on the home page:
import type { GenerateTitle } from '@payloadcms/plugin-seo/types'
const generateTitle: GenerateTitle = ({ doc }) =>
doc?.slug === 'home' || !doc?.title ? 'Acme' : `Acme | ${doc.title}`
ComposiusPayloadPluginPages({
seo: { generateTitle },
})siteName is left out here because the function adds the site name itself. If
you set it as well, it is added to a custom generateTitle too, so the name
would appear twice.
Cache revalidation
Publishing, unpublishing or deleting a page invalidates the collection's Next.js
cache tags, 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/[slug]/page.tsx
import { cacheTag } from 'next/cache'
import { pageTag, PAGES_TAG } from '@composius/payload-plugin-pages/tags'
import { getPayload } from 'payload'
import config from '@payload-config'
const getPage = async (slug: string) => {
'use cache'
cacheTag(pageTag(slug))
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'pages',
where: { slug: { equals: slug } },
})
return docs[0]
}The tags, all exported from @composius/payload-plugin-pages/tags — an entry
point that imports neither payload nor next:
| Tag | Covers |
| ---------------- | ----------------------------------------------------- |
| PAGES_TAG | every page — listings, navigation, sitemaps, search |
| pageTag(slug) | one page, the way a /[slug] route addresses it |
| pageIdTag(id) | one page, by id |
A save invalidates the collection tag and both tags of the document, plus the former slug when a page is renamed — a page that changes address leaves a cache entry behind at the old one.
Autosaved drafts are skipped: nothing about them is public. The hooks run when a page is published, when a published page is saved again, and when one is unpublished or deleted.
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, set
context.disableRevalidate:
await payload.update({
collection: 'pages',
id,
data,
context: { disableRevalidate: true },
})Requirements
The following dependencies are required to be installed in your project before using this plugin:
@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-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 { ComposiusPayloadPluginPages } from '@composius/payload-plugin-pages'
export default buildConfig({
plugins: [ComposiusPayloadPluginPages()],
// ...
})Options
All optional — defaults shown as comments:
ComposiusPayloadPluginPages({
// Access per operation. Defaults: read = published or authenticated,
// create/update/delete = authenticated.
access: { read, create, update, delete },
// Blocks of the `layout` field: defined inline, and/or referenced by slug
// from `config.blocks`. Both join the content block the plugin adds.
blocks: [Hero],
blockReferences: ['hero'],
// Where the prose of a page lives: a block in the layout (default), a fixed
// `content` richText field on the document, or nowhere.
content: 'block',
// Size the editor opens at, and whether the toolbar control is there at all:
// 'small' | 'normal' (default) | 'large' | 'huge', or false to leave it out.
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 a page, used for (live) preview and SEO.
// Default: `${NEXT_PUBLIC_SERVER_URL || SERVER_URL}/${slug}` (pages live at the site root)
pageUrl: (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`;
// `siteNameSeparator` replaces the `|` (default: '|').
seo: {
generateTitle,
generateDescription,
generateImage,
generateURL,
siteName: 'Acme',
siteNameSeparator: '—',
},
// Next.js cache invalidation on save and delete (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.
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:pages # dev Payload app with this plugin
pnpm vitest run packages/payload-plugin-pages/test # unit tests
pnpm vitest run dev/configs/pages # integration tests
pnpm --filter @composius/payload-plugin-pages build # build to dist/See the root README for the release flow.
