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

@focus-reactive/payload-plugin-seo

v1.3.0

Published

Live SEO analysis for [Payload CMS](https://payloadcms.com/) v3 + Next.js, powered by [Yoast](https://github.com/Yoast/wordpress-seo). Adds a real-time SEO drawer to the document editor — keyphrase optimization, on-page checks, readability, inclusive lang

Downloads

822

Readme

@focus-reactive/payload-plugin-seo

Live SEO analysis for Payload CMS v3 + Next.js, powered by Yoast. Adds a real-time SEO drawer to the document editor — keyphrase optimization, on-page checks, readability, inclusive language, content vitals, and a Google SERP preview — without adding a single field to your database.

The plugin injects a button into the editor toolbar of each configured collection. Clicking it opens a drawer that reads the current (unsaved) form values, extracts the title, meta description, slug, body content, and images, and runs the Yoast analysis engine entirely in the browser. Nothing is persisted — there are zero new collections, globals, or fields.


AI Integration Prompt

Copy and paste this prompt into your AI assistant (Cursor, Claude, etc.) to integrate the plugin into an existing Payload + Next.js project.

I want to add live SEO analysis to my Payload CMS v3 + Next.js project using @focus-reactive/payload-plugin-seo.

## How it works

The plugin adds NO database fields, collections, or globals. It injects a button into the
document editor toolbar (admin.components.edit.beforeDocumentControls) of each configured
collection. The button opens a drawer that:
- Reads the live (unsaved) form values for the document
- Extracts title, meta description, slug, body content and images using dot-path config
- Resolves upload/relationship media into <img> tags via the Payload REST API
- Runs the Yoast engine (yoastseo + @yoast/search-metadata-previews) in the browser
- Shows tabs: Keyphrase, On-page SEO, Readability, Inclusive, Content vitals, SERP preview

It works with or without a focus keyphrase; keyphrase-specific checks unlock once you enter one.

## Installation

pnpm add @focus-reactive/payload-plugin-seo

## Step 1 — Register the plugin in payload.config.ts

import { seoPlugin } from '@focus-reactive/payload-plugin-seo'

// Inside buildConfig({ plugins: [...] })
seoPlugin({
  collections: [
    {
      slug: 'pages',
      fields: {
        seoTitle: 'seoTitle',           // dot-path; falls back to useAsTitle / 'title'
        metaDescription: 'metaDescription',
        slug: 'slug',                   // default: 'slug'
        content: 'sections',            // dot-path to the main content field (blocks/richText/textarea)
      },
    },
  ],
  site: { name: 'My Site', baseUrl: 'https://example.com', faviconUrl: '/favicon.ico' },
  supportedLocales: ['en'],            // language packs to load; default ['en']
})

## Step 2 — Import the admin styles

In your Payload admin CSS (e.g. app/(payload)/custom.scss):

@import "@focus-reactive/payload-plugin-seo/admin.css";

## Step 3 — Allow Next.js to transpile the Yoast UI packages

In next.config.mjs:

const nextConfig = {
  transpilePackages: ['@yoast/search-metadata-previews', '@yoast/components'],
}

## Important notes

- The plugin reads UNSAVED form values, so analysis updates live as you type (debounced ~1s).
- `fields.content` should point at your primary body field. The built-in extractor walks
  blocks, arrays, groups, tabs, lexical richText, and uploads, converting them to HTML.
- If the built-in extractor can't reach your content shape, supply `extractContentPath`:
  an importMap module path to a `(formValues) => string | Promise<string>` returning HTML.
- Non-English analysis requires the locale code in `supportedLocales`; the matching Yoast
  language pack is dynamically imported on demand.
- No GA4, no API keys, no server calls except resolving media URLs from your own Payload API.

How It Works

Document editor (configured collection)
        │
        ▼
[ SeoButton ]  ← injected into admin.components.edit.beforeDocumentControls
        │  click
        ▼
   SEO Drawer (client-only)
        │
        ├─ read live form values (title, description, slug, content, keyphrase)
        ├─ collect upload/relationship refs → resolve via /api/{collection}?depth=0&locale=…
        ├─ hydrate values → walk tree → build HTML (lexical → HTML, images → <img>)
        ▼
   Yoast engine (in browser): Paper + EnglishResearcher + SeoAssessor
        │
        ▼
   Tabs: Keyphrase · On-page SEO · Readability · Inclusive · Content vitals · SERP preview

The analysis runs on a ~1 second debounce as form values change. No data is written — the drawer is a pure read-only overlay on top of the editor's current state.


Installation

pnpm add @focus-reactive/payload-plugin-seo

Peer dependencies: payload ^3.0.0, @payloadcms/next ^3.0.0, @payloadcms/ui ^3.0.0, @payloadcms/richtext-lexical ^3.0.0, lucide-react ^0.469.0. next ^14 || ^15 and react/react-dom ^18 || ^19 are optional peers.

The Yoast engine (yoastseo, @yoast/search-metadata-previews) ships as a direct dependency — you don't install it yourself, but you do need to transpile the two UI packages (see Quick Start step 3).


Quick Start

Step 1 — Register the plugin

// payload.config.ts
import { buildConfig } from "payload";
import { seoPlugin } from "@focus-reactive/payload-plugin-seo";

export default buildConfig({
  plugins: [
    seoPlugin({
      collections: [
        {
          slug: "pages",
          fields: {
            seoTitle: "seoTitle",
            metaDescription: "metaDescription",
            slug: "slug",
            content: "sections",
          },
        },
      ],
      site: {
        name: "My Site",
        baseUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:3000",
      },
      supportedLocales: ["en", "de", "fr", "es"],
    }),
  ],
});

This injects the SEO button into the document toolbar of every configured collection. A colored dot on the button reflects the current overall status (good / warn / bad).

Step 2 — Import the admin styles

The drawer's components import their compiled CSS internally, but the package also ships it at ./admin.css so you can include it explicitly in your admin stylesheet:

/* app/(payload)/custom.scss */
@import "@focus-reactive/payload-plugin-seo/admin.css";

Step 3 — Transpile the Yoast UI packages

@yoast/search-metadata-previews and @yoast/components ship CSS inside node_modules, which Next.js (and Turbopack) won't process unless they're listed in transpilePackages:

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ["@yoast/search-metadata-previews", "@yoast/components"],
};

export default nextConfig;

Configuration Reference

Plugin Options

interface SeoPluginConfig {
  /** Skip injection entirely. Default: false */
  disabled?: boolean;
  /** Collections to attach the SEO drawer to. At least one is required. */
  collections: SeoCollectionConfig[];
  /** Site identity used in the SERP preview and permalink. */
  site?: SeoSiteConfig;
  /** Locale codes whose Yoast language packs may be loaded. Default: ['en'] */
  supportedLocales?: string[];
  /** Override the English UI strings (merged with defaults). */
  translations?: Translations;
}

SeoCollectionConfig

interface SeoCollectionConfig {
  /** Collection slug to attach the drawer to. */
  slug: string;
  /** Dot-paths telling the plugin which fields hold the SEO inputs. */
  fields?: SeoFieldPaths;
  /**
   * importMap module-path to a custom client extractor
   * `(formData) => string | Promise<string>` returning HTML.
   * Example: "@/seo/my-extractor#default".
   * Default: built-in smart extractor.
   */
  extractContentPath?: string;
}

SeoFieldPaths

interface SeoFieldPaths {
  /** Dot-path to the SEO title. Falls back to the collection's useAsTitle / `title`. */
  seoTitle?: string;
  /** Dot-path to the meta description. Absent → meta-description checks are disabled
   *  and the SERP snippet shows no description. */
  metaDescription?: string;
  /** Dot-path to the slug. Default: 'slug' */
  slug?: string;
  /** Dot-path to the primary content field (blocks / richText / textarea). */
  content?: string;
}

Dot-paths support nesting, e.g. "meta.description" or "content.body".

SeoSiteConfig

interface SeoSiteConfig {
  /** Site name shown in the SERP preview. */
  name?: string;
  /** Base URL used to build the permalink in the SERP preview. */
  baseUrl?: string;
  /** Favicon shown in the SERP preview. */
  faviconUrl?: string;
}

Custom content extractor

type ExtractorFn = (data: Record<string, unknown>) => string | Promise<string>;

Provide one when the built-in extractor can't reconstruct your content shape. It receives the raw (unhydrated) form values and must return an HTML string. Reference it from config by importMap path:

// payload.config.ts
seoPlugin({
  collections: [
    { slug: "pages", extractContentPath: "@/seo/my-extractor#default" },
  ],
});
// src/seo/my-extractor.ts
export default function extractContent(data: Record<string, unknown>): string {
  return `<h1>${data.title}</h1><p>${data.body}</p>`;
}

Content Extraction

When extractContentPath is not set, the plugin's built-in extractor:

  1. Reads the value at fields.content from the live form values.
  2. Collects upload / relationship references by walking the form schema (arrays, blocks, groups, tabs, rows, collapsibles, and lexical richText, including inline media nodes).
  3. Resolves media by calling your Payload REST API per collection: GET /api/{collection}?depth=0&locale={locale}&where[id][in][]=… — fetching each doc's url, mimeType, and alt. Results are cached in-memory and invalidated when the drawer re-opens or content changes.
  4. Hydrates the value tree (upload IDs → full docs) and walks it to build HTML:
    • Lexical richText → HTML via @payloadcms/richtext-lexical/html
    • { url, mimeType: "image/*", alt? }<img src="…" alt="…" />
    • { url, label | text | title }<a href="…">…</a>
    • Strings → <p>…</p>
    • Structural keys (id, blockType, blockName, _template, order) are skipped

This means image checks (alt text, keyphrase in alt, image count) work against the real, resolved media — not raw relationship IDs.


The Analysis Drawer

The drawer presents six tabs, all derived from a single in-browser Yoast analysis pass (a Paper analyzed by SeoAssessor with the language-appropriate Researcher):

| Tab | What it checks | | --------------------- | ------------------------------------------------------------------------------ | | Keyphrase | Focus keyphrase usage — in title, slug, meta description, first paragraph, density, image alt, synonyms. Enter a keyphrase to unlock these checks. | | On-page SEO | Title width, meta description presence/length, internal & outbound links, heading structure. | | Readability | Sentence/paragraph length, transition words, passive voice, consecutive sentences. | | Inclusive | Flags potentially exclusionary or non-inclusive language. | | Content vitals | Word count, sentence/paragraph counts, image & video counts, reading time, prominent words. | | Search result preview | Live Google SERP preview (desktop + mobile) with keyphrase highlighting, built on @yoast/search-metadata-previews. |

Without a keyphrase: the drawer still runs and the On-page, Readability, Inclusive, Content vitals, and SERP tabs all populate. Only the keyphrase-specific assessments wait until you type a focus keyphrase and analysis runs.


Localization

supportedLocales lists which locale codes the drawer may load Yoast language packs for. English is built in; other languages are dynamically imported on demand the first time the document is edited in that locale:

seoPlugin({
  collections: [{ slug: "pages", fields: { content: "sections" } }],
  supportedLocales: ["en", "de", "fr", "es"],
});

The active locale is taken from the admin and normalized to Yoast's xx_XX form (e.g. enen_EN). Media is resolved per-locale so localized URLs and alt text are analyzed correctly. A locale not listed in supportedLocales falls back to English processing.


Exports Reference

| Import path | Exports | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | @focus-reactive/payload-plugin-seo | seoPlugin, and types SeoPluginConfig, SeoCollectionConfig, SeoFieldPaths, SeoSiteConfig, ExtractorFn | | @focus-reactive/payload-plugin-seo/admin.css | Compiled admin styles for the drawer & button | | @focus-reactive/payload-plugin-seo/components/SeoButton | SeoButton — the toolbar button component (wired automatically by the plugin via the importMap; you normally never import this directly) |


License

MIT © FocusReactive