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-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 media upload 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, callToAction

Blocks 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 control

The 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#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:

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#EditorLinkEmphasisFeatureClient into the admin panel: run payload generate:importmap after 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#BlocksFeatureClient and the title field component into the admin panel: run payload generate:importmap after 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 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 { 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.