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

@fxmk/cms-plugin

v0.9.3

Published

A blank template to get started with Payload 3.0

Readme

Payload Plugin Template

A template repo to create a Payload CMS plugin.

Brand URL Model

Brands own their URL namespace through the localized rootPath field. Use rootPath as the preferred public API for brand routing and URL-prefix logic. Every page assigned to a brand must use either the brand's root path itself or a child path below it. Nested root paths are valid: for example, one default brand can own / while more specific brands own /aqua and /azul. When multiple brand root paths match a URL, the most specific root path wins.

The homeLink field is kept for backwards compatibility with existing API consumers. It is hidden from editors and managed by the plugin: when a page's localized pathname matches the brand's localized rootPath, that page becomes the derived home link. New consumers should not treat homeLink as the source of truth for a brand's URL prefix.

For existing projects that already have production brands with homeLink data but no rootPath, deploy the plugin version that includes rootPath, then run a Payload migration that calls migrateBrandHomeLinksToRootPaths once.

import { migrateBrandHomeLinksToRootPaths } from "@fxmk/cms-plugin";

await migrateBrandHomeLinksToRootPaths({
  payload,
  req,
});

Review the returned skipped and missing counts before removing any legacy assumptions in your application.

Brand Theme Color Model

Brands expose an explicit themeColor field so editors can choose a visual theme independently from the brand identity. Configure the available theme tokens in cmsPlugin; the first option is used as the default for newly created brands.

cmsPlugin({
  themeColors: [
    { label: "Puerta", value: "puerta" },
    { label: "Aqua", value: "aqua" },
    { label: "Azul", value: "azul" },
  ],
  mediaS3Storage: {
    accessKeyId: process.env.MEDIA_S3_ACCESS_KEY_ID || "",
    bucket: process.env.MEDIA_S3_BUCKET || "",
    region: process.env.MEDIA_S3_REGION || "",
    secretAccessKey: process.env.MEDIA_S3_SECRET_ACCESS_KEY || "",
  },
});

Existing brand documents created before themeColor existed need to be backfilled before frontends switch from brand-id-based theme lookup to brand.themeColor. Run a Payload migration that maps existing brand IDs to the desired theme token.

import { migrateBrandThemeColors } from "@fxmk/cms-plugin";

await migrateBrandThemeColors({
  payload,
  req,
  defaultThemeColor: "puerta",
  themeColorByBrandId: {
    puerta: "puerta",
    aqua: "aqua",
    azul: "azul",
  },
});

Review brandsUsingDefaultThemeColor in the returned result. A non-zero value can be expected for intentional default-brand fallbacks, but it is also a useful signal that a newly discovered brand ID was not included in the mapping.

Payload is built with a robust infrastructure intended to support Plugins with ease. This provides a simple, modular, and reusable way for developers to extend the core capabilities of Payload.

To build your own Payload plugin, all you need is:

  • An understanding of the basic Payload concepts
  • And some JavaScript/Typescript experience

Background

Here is a short recap on how to integrate plugins with Payload, to learn more visit the plugin overview page.

How to install a plugin

To install any plugin, simply add it to your payload.config() in the Plugin array.

import myPlugin from "my-plugin";

export const config = buildConfig({
  plugins: [
    // You can pass options to the plugin
    myPlugin({
      enabled: true,
    }),
  ],
});

Initialization

The initialization process goes in the following order:

  1. Incoming config is validated
  2. Plugins execute
  3. Default options are integrated
  4. Sanitization cleans and validates data
  5. Final config gets initialized

Media organization

The plugin organizes uploads with the legacy media category collection by default. Consumers can opt into Payload's native folders, or disable built-in media organization entirely:

cmsPlugin({
  media: {
    organization: "folders",
  },
  mediaS3Storage: {
    accessKeyId: process.env.MEDIA_S3_ACCESS_KEY_ID || "",
    bucket: process.env.MEDIA_S3_BUCKET || "",
    region: process.env.MEDIA_S3_REGION || "",
    secretAccessKey: process.env.MEDIA_S3_SECRET_ACCESS_KEY || "",
  },
});

Available organization modes:

  • "categories" keeps the existing mediaCategory collection and category relationship field. This is the default.
  • "folders" enables Payload folders on the media collection and the browse-by-folder admin view.
  • "none" leaves media without plugin-provided categories or folders.

For existing projects migrating from categories to folders, deploy once with retainLegacyCategories: true, run a Payload migration that calls migrateMediaCategoriesToFolders, verify the media folder assignments, and then remove retainLegacyCategories.

cmsPlugin({
  media: {
    organization: "folders",
    retainLegacyCategories: true,
  },
  mediaS3Storage: {
    accessKeyId: process.env.MEDIA_S3_ACCESS_KEY_ID || "",
    bucket: process.env.MEDIA_S3_BUCKET || "",
    region: process.env.MEDIA_S3_REGION || "",
    secretAccessKey: process.env.MEDIA_S3_SECRET_ACCESS_KEY || "",
  },
});
import { migrateMediaCategoriesToFolders } from "@fxmk/cms-plugin";

await migrateMediaCategoriesToFolders({
  payload,
  req,
});

By default the migration helper creates or reuses top-level folders. Pass parentFolderID if migrated category folders should live under a specific existing folder.

The helper automatically retries transient MongoDB transaction/catalog-change errors by default, including TransientTransactionError and WriteConflict. This makes first deploys less flaky when Payload introduces the payload-folders collection and the migration immediately writes folders.

Because Payload runs migrations in a transaction when req is passed, the helper defaults to disableTransaction: true for its Local API calls and relies on its idempotent folder reuse and media-skip behavior across retries. Pass disableTransaction: false to keep all helper writes inside Payload's migration transaction, or retry: false to disable helper retries.

Building the Plugin

When you build a plugin, you are purely building a feature for your project and then abstracting it outside of the project.

Template Files

In the Payload plugin template, you will see a common file structure that is used across all plugins:

  1. root folder
  2. /src folder
  3. /dev folder

Root

In the root folder, you will see various files that relate to the configuration of the plugin. We set up our environment in a similar manner in Payload core and across other projects, so hopefully these will look familiar:

  • README.md* - This contains instructions on how to use the template. When you are ready, update this to contain instructions on how to use your Plugin.
  • package.json* - Contains necessary scripts and dependencies. Overwrite the metadata in this file to describe your Plugin.
  • .eslint.config.js - Eslint configuration for reporting on problematic patterns.
  • .gitignore - List specific untracked files to omit from Git.
  • .prettierrc.json - Configuration for Prettier code formatting.
  • tsconfig.json - Configures the compiler options for TypeScript
  • .swcrc - Configuration for SWC, a fast compiler that transpiles and bundles TypeScript.
  • vitest.config.js - Config file for Vitest, defining how tests are run and how modules are resolved

IMPORTANT*: You will need to modify these files.

Dev

In the dev folder, you’ll find a basic payload project, created with npx create-payload-app and the blank template.

IMPORTANT: Make a copy of the .env.example file and rename it to .env. Update the DATABASE_URI to match the database you are using and your plugin name. Update PAYLOAD_SECRET to a unique string. You will not be able to run pnpm/yarn dev until you have created this .env file.

myPlugin has already been added to the payload.config() file in this project.

plugins: [
  myPlugin({
    collections: {
      posts: true,
    },
  }),
];

Later when you rename the plugin or add additional options, make sure to update it here.

You may wish to add collections or expand the test project depending on the purpose of your plugin. Just make sure to keep this dev environment as simplified as possible - users should be able to install your plugin without additional configuration required.

When you’re ready to start development, initiate the project with pnpm/npm/yarn dev and pull up http://localhost:3000 in your browser.

Src

Now that we have our environment setup and we have a dev project ready to - it’s time to build the plugin!

index.ts

The essence of a Payload plugin is simply to extend the payload config - and that is exactly what we are doing in this file.

export const myPlugin =
  (pluginOptions: MyPluginConfig) =>
  (config: Config): Config => {
    // do cool stuff with the config here

    return config;
  };

First, we receive the existing payload config along with any plugin options.

From here, you can extend the config as you wish.

Finally, you return the config and that is it!

Spread Syntax

Spread syntax (or the spread operator) is a feature in JavaScript that uses the dot notation (...) to spread elements from arrays, strings, or objects into various contexts.

We are going to use spread syntax to allow us to add data to existing arrays without losing the existing data. It is crucial to spread the existing data correctly – else this can cause adverse behavior and conflicts with Payload config and other plugins.

Let’s say you want to build a plugin that adds a new collection:

config.collections = [
  ...(config.collections || []),
  // Add additional collections here
];

First we spread the config.collections to ensure that we don’t lose the existing collections, then you can add any additional collections just as you would in a regular payload config.

This same logic is applied to other properties like admin, hooks, globals:

config.globals = [
  ...(config.globals || []),
  // Add additional globals here
];

config.hooks = {
  ...(incomingConfig.hooks || {}),
  // Add additional hooks here
};

Some properties will be slightly different to extend, for instance the onInit property:

import { onInitExtension } from "./onInitExtension"; // example file

config.onInit = async (payload) => {
  if (incomingConfig.onInit) await incomingConfig.onInit(payload);
  // Add additional onInit code by defining an onInitExtension function
  onInitExtension(pluginOptions, payload);
};

If you wish to add to the onInit, you must include the async/await. We don’t use spread syntax in this case, instead you must await the existing onInit before running additional functionality.

In the template, we have stubbed out some addition onInit actions that seeds in a document to the plugin-collection, you can use this as a base point to add more actions - and if not needed, feel free to delete it.

Types.ts

If your plugin has options, you should define and provide types for these options.

export type MyPluginConfig = {
  /**
   * List of collections to add a custom field
   */
  collections?: Partial<Record<CollectionSlug, true>>;
  /**
   * Disable the plugin
   */
  disabled?: boolean;
};

If possible, include JSDoc comments to describe the options and their types. This allows a developer to see details about the options in their editor.

Testing

Having a test suite for your plugin is essential to ensure quality and stability. Vitest is a fast, modern testing framework that works seamlessly with Vite and supports TypeScript out of the box.

Vitest organizes tests into test suites and cases, similar to other testing frameworks. We recommend creating individual tests based on the expected behavior of your plugin from start to finish.

Writing tests with Vitest is very straightforward, and you can learn more about how it works in the Vitest documentation.

For this template, we stubbed out int.spec.ts in the dev folder where you can write your tests.

describe('Plugin tests', () => {
  // Create tests to ensure expected behavior from the plugin
  it('some condition that must be met', () => {
   // Write your test logic here
   expect(...)
  })
})

Best practices

With this tutorial and the plugin template, you should have everything you need to start building your own plugin. In addition to the setup, here are other best practices aim we follow:

  • Providing an enable / disable option: For a better user experience, provide a way to disable the plugin without uninstalling it. This is especially important if your plugin adds additional webpack aliases, this will allow you to still let the webpack run to prevent errors.
  • Include tests in your GitHub CI workflow: If you’ve configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about how to configure tests into your GitHub CI workflow.
  • Publish your finished plugin to NPM: The best way to share and allow others to use your plugin once it is complete is to publish an NPM package. This process is straightforward and well documented, find out more creating and publishing a NPM package here..
  • Add payload-plugin topic tag: Apply the tag **payload-plugin **to your GitHub repository. This will boost the visibility of your plugin and ensure it gets listed with existing payload plugins.
  • Use Semantic Versioning (SemVar) - With the SemVar system you release version numbers that reflect the nature of changes (major, minor, patch). Ensure all major versions reference their Payload compatibility.

Questions

Please contact Payload with any questions about using this plugin template.