Payload Plugins
Plugins

payload-dev-tools

A utility to help you build Payload CMS projects faster.

For AI / LLMs: View Markdown

Dev tools that make building a Payload CMS site easier. You get a floating utility menu on every page, optional /dev routes that show what's in your app (seeded content, icons, fonts, images), and live info for the other Pro Laico plugins you have installed. It also makes it easy for you or your AI to test page layouts, headers, footers, blocks, and more, right inside your real site. Everything is dev-only and disappears in production.

pnpm add @pro-laico/payload-dev-tools

What's included

  • The /dev pages. Real routes inside your app from one catch-all file: /dev (overview, seed controls, collection counts), /dev/icons (glyph grid, switch the active set in one click), /dev/fonts (specimens in your actual served fonts), /dev/images, /dev/mux, and /dev/tests/<test> (one page per test, toolbar toggles the version).
  • <DevToolbar />. A floating corner button on every page (admin included) that navigates the dev pages, toggles test versions, seeds, and reads diagnostics. Stays open while you browse. Self-styled: no Tailwind, no CSS import, no isDev conditional.
  • GET /api/dev. A machine-readable snapshot: environment, installed plugins, seed status and counts, icon misses, font slots, mux readiness, per-collection doc counts. Point an AI agent here first. Browsers get redirected to /dev.
  • Plugin-aware, import-free. Sibling @pro-laico/* plugins are discovered through their config.custom markers. None are dependencies; panels and pages appear for whatever is installed.

Dev tool menu component (bottom right) on Pages view, rendered on the /dev/fonts routeDev tool menu component (bottom right) on Pages view, rendered on the /dev/fonts route

Quickstart

Add the plugin

payload.config.ts
import { buildConfig } from 'payload'
import { devToolsPlugin } from '@pro-laico/payload-dev-tools'

export default buildConfig({
  // …
  plugins: [devToolsPlugin()],
})

Registers GET /api/dev (snapshot), GET /api/dev/stage (URL staging), and POST /api/dev/icons/activate (set switcher). All 404 outside development.

Drop in the dev pages

app/(frontend)/dev/[[...view]]/page.tsx
import { createDevPage } from '@pro-laico/payload-dev-tools/next'

export const dynamic = 'force-dynamic'
export default createDevPage()

One file, all views. It lives in your (frontend) group, so the pages inherit your layout, fonts, and styles. createDevPage resolves Payload itself; no props required.

Mount the toolbar

app/(frontend)/layout.tsx
import { DevToolbar } from '@pro-laico/payload-dev-tools/toolbar'

export default function Layout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <DevToolbar />
      </body>
    </html>
  )
}

No isDev check needed; it renders null in production. If you want it on all your pages, admin included, also add the same line inside <RootLayout> in Payload's app/(payload)/layout.tsx. Because it lives in the layout, the panel survives client-side navigation.

Optionally, register tests

src/dev/tests.tsx
import { defineTest } from '@pro-laico/payload-dev-tools/next'

export const heroTest = defineTest({
  key: 'hero',
  label: 'Homepage hero',
  kind: 'page',
  versions: [
    { id: 'bold', label: 'Bold', render: () => <BoldHero /> },
    { id: 'split', label: 'Split', render: async () => <SplitHero data={await load()} /> },
  ],
})

Pass the same array to both frontend pieces:

export default createDevPage({ tests: [heroTest] })   // → the page: /dev/tests/hero
<DevToolbar tests={[heroTest]} />                      // → the controls: open it, toggle versions

Each test is one page. The toolbar's chips set a cookie that picks the version: click Bold, look; click Split, compare.

Header/footer tests go further: with one more line in your layout, picking a header/footer version swaps it into the real layout, site-wide, until you hit Real:

app/(frontend)/layout.tsx
const { header, footer } = await resolveDevChrome({ tests: devTests, header: <SiteHeader />, footer: <SiteFooter /> })
// …render {header} / {footer} where the real ones went

In production resolveDevChrome returns exactly what you passed in (before touching cookies, so static optimization is unaffected). A variant that throws falls back to the real chrome.

Plugin options

enabledbooleandefault NODE_ENV === 'development'

Forces the endpoints on or off regardless of environment. The default gates everything to development. Only force true on a deployment you'd hand a teammate anyway (a preview env): the snapshot exposes collection counts and config details, unauthenticated.

devRoutestringdefault '/dev'

Where the host app mounts the createDevPage catch-all. Drives the toolbar's built-in link and the browser redirect from GET /api/dev.

interface DevToolsPluginOptions {
  enabled?: boolean
  devRoute?: string // default '/dev'
}

createDevPage({ tests, enabled }) and <DevToolbar tests links enabled /> take the same enabled override and the same tests array (from defineTest). The toolbar's links adds extra rows to its Pages view; point them at your own labs.

The /dev pages

The pages render content only; navigation between them is the toolbar's Pages view, which stays open as you browse. Views light up based on which plugins are installed:

RouteWhat you see
/devEnv + plugin chips, seed card (seed / destructive reseed with errors inline), doc counts.
/dev/iconsEvery icon set as a button. Click one to activate it and the whole site re-skins. Plus the active set's glyph grid and runtime misses.
/dev/fontsInteractive specimen per family slot. Click through only the weights and styles the typeface actually serves.
/dev/imagesOriginals rendered through /api/img/:id?w=320, exercising the transform + cache end-to-end. Payload folders render as filter chips.
/dev/muxVideos with their persisted ingest status.
/dev/tests/<key>One page per test, showing the toolbar-selected version. No added chrome, so you see the component exactly as it would ship.

Your own labs coexist: a static route like app/(frontend)/dev/blocks/page.tsx always beats the catch-all, and the toolbar's links puts it in the Pages view.

The snapshot: GET /api/dev

JSON for everything non-human (?format=json also forces it); browsers are redirected to /dev.

curl -s localhost:3000/api/dev | jq '.seed.seeded, .icons.misses'
{
  "env": { "nodeEnv": "development", "nodeVersion": "v22.x" },
  "plugins": { "seed": true, "images": true, "icons": true, "fonts": true, "mux": true },
  "seed": { "enabled": true, "seeded": true, "totalDocs": 42, "counts": { "services": 4 }, "definitions": [/* … */] },
  "images": { "sourceSlug": "images", "basePath": "/api/img", "sourceCount": 12, "variantCount": 96 },
  "icons": { "iconCount": 9, "activeSet": "Brand icons", "misses": [{ "name": "sparkles", "count": 7 }] },
  "fonts": { "familyKeys": ["sans", "serif"], "slots": { "sans": "Inter", "serif": null } },
  "mux": { "slug": "mux-video", "credentialed": false, "total": 0, "ready": 0 },
  "collections": [{ "slug": "services", "count": 4 }/* … */],
  "globals": ["site-settings"],
  "devRoute": "/dev"
}

Each plugin panel is null when that plugin isn't installed. One request answers "what is this app, is it seeded, and what's broken."

The test harness

defineTest registers a named test with versions; each version's render is a prop-less (optionally async) server component. Fetch your own data inside it. The model is one-page-per-test:

  • The page. /dev/tests/<key> renders inside your real layout with no added chrome. Which version it shows comes from a session cookie (defaulting to the first version), so the URL stays stable while you compare.
  • The controller. The toolbar's Tests view names what you're viewing; its chips flip the cookie and re-render the page with the other version.
  • Chrome overrides. header/footer-kind tests get Real/variant chips instead of a page. Selecting one sets a slot cookie that resolveDevChrome reads to swap the variant into the real chrome on every route. Browse the whole site wearing the candidate header.
  • The script hook. GET /api/dev/stage?test=hero&version=bold sets the cookie and redirects to the test's page, so an agent or screenshot script puts any version on screen with one URL (?clear=1 resets; ?slot=header|footer targets a chrome override). A layout-invisible wrapper (display: contents) carries data-pdt-test / data-pdt-version to assert against.

How it works

  • Plugin discovery is marker-based. Each @pro-laico/* plugin stashes its resolved slugs and options on config.custom (payloadSeed, payloadImages, payloadIcons, payloadFonts, payloadMux). The snapshot builder reads those markers off payload.config. No plugin imports, so nothing else is a dependency; third-party setups just show fewer panels.
  • The dev pages resolve Payload themselves. createDevPage reads the config from a globalThis stash the plugin's onInit fills (falling back to the @payload-config alias), so the drop-in file passes no config.
  • Seeding goes through the seed plugin's own endpoint. The seed card and toolbar POST to /api/seed: same gate (ENABLE_SEED=true + a logged-in user), same { error, issues } responses, surfaced inline.
  • The toolbar persists because it lives in the layout. Its rows are client-side <Link>s, so navigating between dev pages never remounts it. It injects one <style> tag (all .pdt-/.pdtp- prefixed, no host theme dependency; --pdt-accent is the override seam), and it only ever receives test labels, never render functions.
  • Gating is layered. Endpoints check per request, pages and toolbar per render. A production build ships neither markup nor data.
  • It sits beside Next's own dev indicator, on purpose. The launcher borrows its idiom and defaults to the opposite corner (Next's is bottom-left).

Gotchas

A few things to keep in mind:

  • Three entry points, three contexts. . (the plugin) is for payload.config; /toolbar is a React component for your layouts; /next boots Payload. Import each only where it belongs; mixing them up fails at build time.
  • Add export const dynamic = 'force-dynamic' to the drop-in file. The dev pages read live data on every request; without it a statically-optimized build can freeze them.
  • The panel persists per layout tree. Client-side navigation within your (frontend) group keeps it open; jumping to the admin (a different root layout) remounts it closed. That's a Next boundary, not a setting.
  • Chrome swaps need the resolveDevChrome line. Without it, header/footer chips set their cookie but nothing reads it. The override is site-wide in dev by design; the toolbar's Real chip (or ?slot=…&clear=1) is the way back.
  • enabled: true outside dev publishes your counts. The snapshot and pages are deliberately unauthenticated for local convenience; flipping them on in a deployed env exposes collection counts, slugs, and seed state to anyone with the URL.
  • The Seed view needs the seed plugin's preconditions. ENABLE_SEED=true in .env.local and a logged-in Payload user. The card tells you which one is missing, but it can't remove them.

Exports

ExportFromWhat
devToolsPlugin@pro-laico/payload-dev-toolsPlugin factory; registers the /api/dev* endpoints.
defineTest@pro-laico/payload-dev-tools (and /next, /toolbar)Identity helper defining a test's versions with full typing.
DevSnapshot (+ per-plugin types)@pro-laico/payload-dev-toolsThe GET /api/dev response shape, for typed consumers.
STAGE_COOKIE@pro-laico/payload-dev-toolsThe stage cookie name, for custom staging tooling.
createDevPage@pro-laico/payload-dev-tools/nextThe /dev pages; one catch-all drop-in file.
DevToolbar@pro-laico/payload-dev-tools/toolbarThe floating toolbar server component; one line in your layout.
resolveDevChrome@pro-laico/payload-dev-tools/toolbarThe chrome-swap seam: returns the real header/footer, or the toolbar-selected variant.

On this page