payload-dev-tools
A utility to help you build Payload CMS projects faster.
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-toolsWhat's included
- The
/devpages. 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, noisDevconditional.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 theirconfig.custommarkers. 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 route
Quickstart
Add the plugin
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
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
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
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 versionsEach 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:
const { header, footer } = await resolveDevChrome({ tests: devTests, header: <SiteHeader />, footer: <SiteFooter /> })
// …render {header} / {footer} where the real ones wentIn 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:
| Route | What you see |
|---|---|
/dev | Env + plugin chips, seed card (seed / destructive reseed with errors inline), doc counts. |
/dev/icons | Every 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/fonts | Interactive specimen per family slot. Click through only the weights and styles the typeface actually serves. |
/dev/images | Originals rendered through /api/img/:id?w=320, exercising the transform + cache end-to-end. Payload folders render as filter chips. |
/dev/mux | Videos 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 thatresolveDevChromereads 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=boldsets the cookie and redirects to the test's page, so an agent or screenshot script puts any version on screen with one URL (?clear=1resets;?slot=header|footertargets a chrome override). A layout-invisible wrapper (display: contents) carriesdata-pdt-test/data-pdt-versionto assert against.
How it works
- Plugin discovery is marker-based. Each
@pro-laico/*plugin stashes its resolved slugs and options onconfig.custom(payloadSeed,payloadImages,payloadIcons,payloadFonts,payloadMux). The snapshot builder reads those markers offpayload.config. No plugin imports, so nothing else is a dependency; third-party setups just show fewer panels. - The dev pages resolve Payload themselves.
createDevPagereads the config from aglobalThisstash the plugin'sonInitfills (falling back to the@payload-configalias), 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-accentis 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 forpayload.config;/toolbaris a React component for your layouts;/nextboots 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
resolveDevChromeline. Without it,header/footerchips 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: trueoutside 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=truein.env.localand a logged-in Payload user. The card tells you which one is missing, but it can't remove them.
Exports
| Export | From | What |
|---|---|---|
devToolsPlugin | @pro-laico/payload-dev-tools | Plugin 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-tools | The GET /api/dev response shape, for typed consumers. |
STAGE_COOKIE | @pro-laico/payload-dev-tools | The stage cookie name, for custom staging tooling. |
createDevPage | @pro-laico/payload-dev-tools/next | The /dev pages; one catch-all drop-in file. |
DevToolbar | @pro-laico/payload-dev-tools/toolbar | The floating toolbar server component; one line in your layout. |
resolveDevChrome | @pro-laico/payload-dev-tools/toolbar | The chrome-swap seam: returns the real header/footer, or the toolbar-selected variant. |