Payload Plugins
Pluginspayload-dev-tools

payload-dev-tools

A dev-only toolbar, live /dev pages, and a machine-readable app snapshot that make building a Payload site 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 and optional /dev routes that show what's actually in your app.

See your seeded content, icons, fonts, images, and videos rendered in your real layout. Test page, header, footer, and block variants right inside the running site. Point an AI agent at one snapshot URL to learn the whole app.

Works on its own; extra panels light up when you also run the other @pro-laico/* plugins. Everything is dev-only and disappears in production.

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

Requirements

Next.js 15+ on the App Router, plus Payload ^3 and React 19. This one is Next-shaped by nature: the toolbar and the /dev pages are Next components (next/link, next/navigation), and the draft-mode toggle and chrome resolver read cookies() / draftMode() from next/headers. There's no framework-agnostic subset to fall back on — the whole surface is the UI.

No next.config changes are needed, though: everything mounts through the plugin, one page file, and one layout line.

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, /dev/revalidate, 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 and Next.js draft mode, seeds, and reads diagnostics. Stays open while you browse. Self-styled: no Tailwind, no CSS import, no isDev conditional.
  • A location toggle. Pick a region in the toolbar and your app renders as it would for a visitor there — GDPR opt-in, CCPA opt-out, or neither. One resolver call, no per-country branching.
  • Environment awareness. payload-dev-env staging -- pnpm dev boots against another env file without touching .env.local, and the toolbar names the env you're in, whether the database is local, and which variables the installed plugins are missing.
  • 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

No next.config changes are needed. Everything mounts through the plugin, one page file, and one layout line.

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), GET /api/dev/region (location override), GET /api/dev/draft (draft-mode toggle), and POST /api/dev/icons/activate (set switcher). All 404 outside development.

Drop in the dev pages

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

const DevPage = createDevPage({ payload: getPayload({ config }) })

export default function Page(props: Parameters<typeof DevPage>[0]) {
  return (
    <Suspense fallback={null}>
      <DevPage {...props} />
    </Suspense>
  )
}

One file, all views. It lives in your (frontend) group, so the pages inherit your layout, fonts, and styles. Pass createDevPage a Payload handle — getPayload({ config }) from your app's @payload-config. The pages read live data, so render them inside <Suspense> — a dynamic hole that streams at request time. Don't reach for export const dynamic = 'force-dynamic' — these pages only ever render in development, where nothing prerenders anyway.

Worth knowing if you write your own dev pages: Next marks a subtree dynamic when it sees fetch, cookies(), or headers() — it cannot see a database read through Payload's Local API, so a prerender would resolve one and freeze the result. It never bites here because these pages don't run under NODE_ENV=production, but your own pages have no such clamp.

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.

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.

Register your tests

The test harness is the reason to reach for this plugin in earnest. Define your variants once, then flip between them from the toolbar. Start with a page test and a header test:

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()} /> },
  ],
})

export const headerTest = defineTest({
  key: 'site-header',
  label: 'Site header',
  kind: 'header',
  versions: [
    { id: 'compact', label: 'Compact', render: () => <CompactHeader /> },
    { id: 'mega', label: 'Mega menu', render: () => <MegaHeader /> },
  ],
})

export const devTests = [heroTest, headerTest]

Pass the same devTests array to both frontend pieces:

export default createDevPage({ payload: getPayload({ config }), tests: devTests })   // → the page: /dev/tests/hero
<DevToolbar tests={devTests} />                                                      // → the controls: open it, toggle versions

Each page 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 version swaps it into the real layout, site-wide, until you hit Real:

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

export default async function Layout({ children }) {
  const { header, footer } = await resolveDevChrome({
    tests: devTests,
    header: <SiteHeader />,
    footer: <SiteFooter />,
  })
  return (
    <html lang="en">
      <body>
        {header}
        {children}
        {footer}
        <DevToolbar tests={devTests} />
      </body>
    </html>
  )
}

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. See The test harness for the full model.

Plugin options

Zero-config by default — every option has a sensible default. See Reference for options, env vars, endpoints, and exports.

Explore

On this page