payload-puck
v0.6.15
/

Visual Page Builder for Payload CMS

Drag-and-drop page building with 15+ components, rich text editing, AI-assisted generation, and seamless Payload integration.

Installation

Requirements

DependencyVersionPurpose
@puckeditor/core≥ 0.21.0Visual editor core
payload≥ 3.69.0CMS backend
@payloadcms/next≥ 3.69.0Payload Next.js integration
next≥ 15.4.8React framework
react≥ 19.2.1UI library
@tailwindcss/typography≥ 0.5.0RichText component styling

Note: Puck 0.21+ moved from @measured/puck to @puckeditor/core. This plugin requires the new package scope.

Install

pnpm add @delmaredigital/payload-puck @puckeditor/core

Quick Start

The plugin integrates directly into Payload's admin UI with minimal configuration. API endpoints and admin views are registered automatically.

Add the Plugin

ts// src/payload.config.ts
import { buildConfig } from 'payload'
import { createPuckPlugin } from '@delmaredigital/payload-puck/plugin'

export default buildConfig({
  plugins: [
    createPuckPlugin({
      pagesCollection: 'pages', // Collection slug (default: 'pages')
    }),
  ],
  // ...
})

This automatically:

  • Creates a pages collection with Puck fields (or adds fields to your existing collection)
  • Registers API endpoints at /api/puck/:collection
  • Adds the Puck editor view at /admin/puck-editor/:collection/:id
  • Adds "Edit with Puck" buttons to the admin UI

Provide Puck Configuration

Wrap your app with PuckConfigProvider to supply the Puck configuration. This makes the config available to the editor via React context.

ts// app/(app)/layout.tsx (covers both admin and frontend)
import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <PuckConfigProvider config={editorConfig}>
          {children}
        </PuckConfigProvider>
      </body>
    </html>
  )
}

Tip: PuckConfigProvider also accepts layouts and theme props. See Layouts and Theming sections.

Alternative: Payload Admin Provider pattern

If you're using the vanilla Payload starter structure, you can register the provider via the admin config instead:

ts// src/payload.config.ts
export default buildConfig({
  admin: {
    components: {
      providers: ['@/components/admin/PuckProvider'],
    },
  },
  // ...
})
ts// src/components/admin/PuckProvider.tsx
'use client'

import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'

export default function PuckProvider({ children }: { children: React.ReactNode }) {
  return <PuckConfigProvider config={editorConfig}>{children}</PuckConfigProvider>
}

Create a Frontend Route

The plugin can't auto-create frontend routes (Next.js App Router is file-based), but here's copy-paste ready code:

app/(frontend)/[[...slug]]/page.tsx (click to expand)
tsimport { getPayload } from 'payload'
import config from '@payload-config'
import { PageRenderer } from '@delmaredigital/payload-puck/render'
import { baseConfig } from '@delmaredigital/payload-puck/config'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'

// Fetch page by slug (or homepage if no slug)
async function getPage(slug?: string[]) {
  const payload = await getPayload({ config })
  const slugPath = slug?.join('/') || ''

  const { docs } = await payload.find({
    collection: 'pages',
    where: {
      and: [
        { _status: { equals: 'published' } },
        slugPath
          ? { slug: { equals: slugPath } }
          : { isHomepage: { equals: true } },
      ],
    },
    limit: 1,
  })

  return docs[0] || null
}

// Generate metadata from page SEO fields
export async function generateMetadata({
  params
}: {
  params: Promise<{ slug?: string[] }>
}): Promise<Metadata> {
  const { slug } = await params
  const page = await getPage(slug)

  if (!page) return {}

  return {
    title: page.meta?.title || page.title,
    description: page.meta?.description,
  }
}

// Render the page
export default async function Page({
  params
}: {
  params: Promise<{ slug?: string[] }>
}) {
  const { slug } = await params
  const page = await getPage(slug)

  if (!page) notFound()

  return <PageRenderer config={baseConfig} data={page.puckData} />
}

Note: The [[...slug]] pattern with double brackets makes the slug optional, so this handles both / (homepage) and /any/path.

That's it! The plugin registers the editor view at /admin/puck-editor/:collection/:id. "Edit with Puck" buttons appear in the collection list view. The editor runs inside Payload's admin UI with full navigation. API endpoints are handled automatically via Payload's endpoint system.

Styling Setup

Tailwind Typography

Required only if using the RichText component. The RichText component uses @tailwindcss/typography:

pnpm add @tailwindcss/typography

Tailwind v4

css@import "tailwindcss";
@plugin "@tailwindcss/typography";

Tailwind v3

js// tailwind.config.js
module.exports = {
  plugins: [require('@tailwindcss/typography')],
}

Package Scanning

Required if your project uses Tailwind CSS. Ensures component classes are included in your build.

Tailwind v4

css/* Adjust path relative to your CSS file */
@source "../node_modules/@delmaredigital/payload-puck";

Tailwind v3

js// tailwind.config.js
module.exports = {
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    './node_modules/@delmaredigital/payload-puck/**/*.{js,mjs,jsx,tsx}',
  ],
}
Theme CSS Variables (optional)

The plugin uses shadcn/ui-style CSS variables. If you don't use shadcn/ui and want to customize colors, define these in your CSS:

css:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96%;
  --secondary-foreground: 222.2 47.4% 11.2%;
  --muted: 210 40% 96%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --accent: 210 40% 96%;
  --accent-foreground: 222.2 47.4% 11.2%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 210 40% 98%;
  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;
  --ring: 222.2 84% 4.9%;
  --radius: 0.5rem;
}

Adding to Existing Projects

Important: If you're adding Puck to a project with existing frontend routes, you must update those routes to render Puck content.

When adding Puck to an existing Payload project:

  1. Add the plugin to payload.config.ts
  2. Add PuckConfigProvider to your admin layout
  3. Update your frontend page templates to render puckData

Without step 3, Puck pages will render blank because your existing routes only look for legacy block fields like layout or hero.

Option A: Hybrid Rendering (recommended)

tsimport { HybridPageRenderer } from '@delmaredigital/payload-puck/render'
import { baseConfig } from '@delmaredigital/payload-puck/config'

export default async function Page({ params }) {
  const page = await getPage(params.slug)
  return <HybridPageRenderer page={page} config={baseConfig} />
}

If you're migrating an existing site with legacy Payload blocks, provide a legacyRenderer:

tsimport { HybridPageRenderer } from '@delmaredigital/payload-puck/render'
import { baseConfig } from '@delmaredigital/payload-puck/config'
import { LegacyBlockRenderer } from '@/components/LegacyBlockRenderer'

export default async function Page({ params }) {
  const page = await getPage(params.slug)

  return (
    <HybridPageRenderer
      page={page}
      config={baseConfig}
      legacyRenderer={(blocks) => <LegacyBlockRenderer blocks={blocks} />}
    />
  )
}

Option B: Manual Detection

ts// Check if page was created with Puck
const isPuckPage = page.editorVersion === 'puck' && page.puckData?.content?.length > 0

if (isPuckPage) {
  return <PageRenderer config={baseConfig} data={page.puckData} />
}

// Fall back to legacy rendering
return <LegacyBlockRenderer blocks={page.layout} />

Server vs Client Config

The plugin provides two configurations for React Server Components:

ConfigImportUse Case
baseConfig@delmaredigital/payload-puck/configServer-safe rendering with PageRenderer
editorConfig@delmaredigital/payload-puck/config/editorClient-side editing with full interactivity
ts// Server component - use baseConfig
import { baseConfig } from '@delmaredigital/payload-puck/config'
<PageRenderer config={baseConfig} data={page.puckData} />

// PuckConfigProvider - use editorConfig
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'
<PuckConfigProvider config={editorConfig}>

Draft System

The editor uses Payload's native draft system. The plugin automatically enables drafts on the pages collection. You can also enable it manually:

ts{
  slug: 'pages',
  versions: {
    drafts: true,
  },
}

The editor header provides:

  • Save — Saves as draft without publishing
  • Publish — Publishes the page (sets _status: 'published')
  • Unpublish — Reverts a published page to draft status (appears only when published)

Editor Architecture

The plugin provides a single unified editor component:

  • PuckEditor — Primary public component for all editor use cases
  • Accepts config prop directly OR reads from PuckConfigProvider context
  • Built-in page-tree support via hasPageTree prop
  • Dynamic import internally to prevent hydration mismatches
  • Handles save/publish, unsaved changes tracking, viewport switching

For Payload admin, the server component (PuckEditorView) detects page-tree via collection schema and passes hasPageTree={true} to PuckEditor when detected. Config comes from PuckConfigProvider context.

Built-in Components

Layout

ComponentDescription
ContainerContent wrapper with max-width and background
FlexFlexible box layout with direction and alignment
GridCSS Grid layout with responsive columns
SectionTwo-layer: full-bleed section + constrained content area
SpacerVertical/horizontal spacing element
TemplateSave/load reusable component arrangements

Typography

ComponentDescription
HeadingH1-H6 headings with size and alignment
TextParagraph text with styling options
RichTextPuck's native richtext editor with enhancements: font sizes, text colors with opacity, highlights, superscript/subscript, and inline editing on canvas

Media & Interactive

ComponentDescription
ImageResponsive image with alt text
ButtonStyled button/link with variants
CardContent card with optional image
DividerHorizontal rule with styles
AccordionExpandable content sections (first item opens by default)
Semantic HTML Elements

Layout components support semantic HTML output for better SEO and accessibility:

ComponentAvailable Elements
Sectionsection, article, aside, nav, header, footer, main, div
Flexdiv, nav, ul, ol, aside, section
Containerdiv, article, aside, section
Griddiv, ul, ol

Select the appropriate HTML element in the component's sidebar to output semantic markup.

Responsive Controls: Layout components support per-breakpoint customization for dimensions, padding/margin, and visibility. The editor provides Mobile, Tablet, Desktop, and Full Width viewport preview options.

Custom Fields

All fields are imported from @delmaredigital/payload-puck/fields.

Field Reference

FieldDescription
MediaFieldPayload media library integration
RichTextFieldPuck's native richtext with enhancements (colors, font sizes, highlights)
ColorPickerFieldColor picker with opacity and presets
BackgroundFieldSolid colors, gradients, images
PaddingField / MarginFieldVisual spacing editors
BorderFieldBorder width, style, color, radius
DimensionsFieldWidth/height with constraints
AlignmentFieldText alignment (left, center, right)
ContentAlignmentFieldVisual 3x3 grid selector for positioning (d-pad style)
SizeFieldPreset sizes (sm, default, lg) with custom mode
AnimationFieldEntrance animations
ResponsiveVisibilityFieldShow/hide per breakpoint
FolderPickerFieldHierarchical folder selection (page-tree)
PageSegmentFieldURL segment with slugification (page-tree)
SlugPreviewFieldRead-only computed slug (page-tree)

Usage Example

tsimport { createMediaField, createBackgroundField, backgroundValueToCSS } from '@delmaredigital/payload-puck/fields'

const HeroConfig = {
  fields: {
    image: createMediaField({ label: 'Background Image' }),
    background: createBackgroundField({ label: 'Overlay' }),
  },
  render: ({ image, background }) => (
    <section style={{ background: backgroundValueToCSS(background) }}>
      {/* content */}
    </section>
  ),
}

CSS Helper Functions

tsimport {
  backgroundValueToCSS,
  dimensionsValueToCSS,
  animationValueToCSS,
  visibilityValueToCSS,
  alignmentToFlexCSS,
  alignmentToGridCSS,
  sizeValueToCSS,
  getSizeClasses,
} from '@delmaredigital/payload-puck/fields'
ContentAlignmentField example

The ContentAlignmentField provides a visual 3x3 grid selector for content positioning:

tsimport {
  createContentAlignmentField,
  alignmentToFlexCSS,
  alignmentToGridCSS,
} from '@delmaredigital/payload-puck/fields'

const BannerConfig = {
  fields: {
    contentPosition: createContentAlignmentField({ label: 'Content Position' }),
  },
  render: ({ contentPosition }) => (
    <div style={{
      display: 'flex',
      minHeight: '400px',
      ...alignmentToFlexCSS(contentPosition),
    }}>
      <div>Positioned content</div>
    </div>
  ),
}

Helper functions:

  • alignmentToFlexCSS() — For Flexbox containers (justify-content + align-items)
  • alignmentToGridCSS() — For Grid containers (justify-content + align-content)
  • alignmentToPlaceSelfCSS() — For individual grid items (place-self)
  • alignmentToTailwind() — Returns Tailwind classes (justify-* items-*)

Building Custom Components

The plugin exports individual component configs and field factories for building custom Puck configurations.

Cherry-Picking Components

Import only the components you need:

tsimport {
  SectionConfig,
  HeadingConfig,
  TextConfig,
  ImageConfig,
  ButtonConfig,
} from '@delmaredigital/payload-puck/components'

export const puckConfig: Config = {
  components: {
    Section: SectionConfig,
    Heading: HeadingConfig,
    Text: TextConfig,
    Image: ImageConfig,
    Button: ButtonConfig,
  },
  categories: {
    layout: { components: ['Section'] },
    content: { components: ['Heading', 'Text', 'Image', 'Button'] },
  },
}

Using Field Factories

Build custom components with pre-built fields:

tsimport type { ComponentConfig } from '@puckeditor/core'
import {
  createMediaField,
  createBackgroundField,
  createPaddingField,
  backgroundValueToCSS,
  paddingValueToCSS,
} from '@delmaredigital/payload-puck/fields'

export const HeroConfig: ComponentConfig = {
  label: 'Hero',
  fields: {
    image: createMediaField({ label: 'Background Image' }),
    overlay: createBackgroundField({ label: 'Overlay' }),
    padding: createPaddingField({ label: 'Padding' }),
  },
  defaultProps: {
    image: null,
    overlay: null,
    padding: { top: 80, bottom: 80, left: 24, right: 24, unit: 'px', linked: false },
  },
  render: ({ image, overlay, padding }) => (
    <section
      style={{
        background: backgroundValueToCSS(overlay),
        padding: paddingValueToCSS(padding),
      }}
    >
      {/* Hero content */}
    </section>
  ),
}

Server vs Editor Variants

For PageRenderer (frontend), components need server-safe configs without React hooks:

ts// Import server variants for PageRenderer
import {
  SectionServerConfig,
  HeadingServerConfig,
  TextServerConfig,
} from '@delmaredigital/payload-puck/components'

<PageRenderer
  config={{ components: { Section: SectionServerConfig, ... } }}
  data={page.puckData}
/>

For custom components, create two files:

  • MyComponent.tsx — Full editor version with fields and interactivity
  • MyComponent.server.tsx — Server-safe version (no hooks, no 'use client')

Extending Configs

Use extendConfig() to add custom components to the built-in configs:

tsimport { extendConfig, fullConfig } from '@delmaredigital/payload-puck/config/editor'
import { HeroConfig } from './components/Hero'

export const puckConfig = extendConfig({
  base: fullConfig,
  components: {
    Hero: HeroConfig,
  },
  categories: {
    custom: { title: 'Custom', components: ['Hero'] },
  },
})

Note: Use fullConfig from /config/editor for extending the editor. For server-side rendering, use baseConfig from /config.

Using Custom Config with Provider

ts// components/admin/PuckProvider.tsx
'use client'
import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { puckConfig } from '@/puck/config.editor'
import { siteLayouts } from '@/lib/puck-layouts'

export default function PuckProvider({ children }: { children: React.ReactNode }) {
  return (
    <PuckConfigProvider config={puckConfig} layouts={siteLayouts}>
      {children}
    </PuckConfigProvider>
  )
}

For Payload admin, register the provider in your Payload config:

ts// payload.config.ts
export default buildConfig({
  admin: {
    components: {
      providers: ['@/components/admin/PuckProvider'],
    },
  },
  // ...
})
Available Field Factories
FactoryDescription
createMediaField()Payload media library picker
createBackgroundField()Solid, gradient, or image backgrounds
createColorPickerField()Color picker with opacity
createPaddingField()Visual padding editor
createMarginField()Visual margin editor
createBorderField()Border styling
createDimensionsField()Width/height constraints
createAnimationField()Entrance animations
createAlignmentField()Text alignment (left, center, right)
createContentAlignmentField()Visual 3x3 grid positioning selector
createSizeField()Size presets with custom mode
createRichTextField()Puck's native richtext with colors, font sizes, highlights
createResponsiveVisibilityField()Show/hide per breakpoint
CSS Helper Functions

Convert field values to CSS:

tsimport {
  backgroundValueToCSS,
  paddingValueToCSS,
  marginValueToCSS,
  borderValueToCSS,
  dimensionsValueToCSS,
  colorValueToCSS,
  alignmentToFlexCSS,
  alignmentToGridCSS,
  sizeValueToCSS,
} from '@delmaredigital/payload-puck/fields'

const style = {
  background: backgroundValueToCSS(props.background),
  padding: paddingValueToCSS(props.padding),
  ...dimensionsValueToCSS(props.dimensions),
  ...alignmentToFlexCSS(props.contentAlignment),
  ...sizeValueToCSS(props.size),
}

Theming

Customize button styles, color presets, and focus rings:

tsimport { PageRenderer } from '@delmaredigital/payload-puck/render'
import { ThemeProvider } from '@delmaredigital/payload-puck/theme'

<ThemeProvider theme={{
  buttonVariants: {
    default: { classes: 'bg-primary text-white hover:bg-primary/90' },
    secondary: { classes: 'bg-secondary text-foreground hover:bg-secondary/90' },
  },
  focusRingColor: 'focus:ring-primary',
  colorPresets: [
    { hex: '#3b82f6', label: 'Brand Blue' },
    { hex: '#10b981', label: 'Success' },
  ],
}}>
  <PageRenderer config={baseConfig} data={page.puckData} />
</ThemeProvider>

Access theme values in custom components with useTheme():

tsimport { useTheme } from '@delmaredigital/payload-puck/theme'

function CustomButton({ variant }) {
  const theme = useTheme()
  const classes = theme.buttonVariants[variant]?.classes
  return <button className={classes}>...</button>
}

Layouts

Define page layouts with headers, footers, and styling:

ts// lib/puck-layouts.ts
import type { LayoutDefinition } from '@delmaredigital/payload-puck/layouts'
import { SiteHeader } from '@/components/header'
import { SiteFooter } from '@/components/footer'

export const siteLayouts: LayoutDefinition[] = [
  {
    value: 'default',
    label: 'Default',
    description: 'Standard page with header and footer',
    maxWidth: '1200px',
    header: SiteHeader,
    footer: SiteFooter,
    stickyHeaderHeight: 80,
  },
  {
    value: 'landing',
    label: 'Landing',
    description: 'Full-width landing page',
    fullWidth: true,
  },
]

Pass layouts to the PuckConfigProvider:

ts<PuckConfigProvider config={editorConfig} layouts={siteLayouts}>
  {children}
</PuckConfigProvider>

And use them with PageRenderer:

tsimport { LayoutWrapper } from '@delmaredigital/payload-puck/layouts'

const layout = siteLayouts.find(l => l.value === page.puckData?.root?.props?.pageLayout)

<LayoutWrapper layout={layout}>
  <PageRenderer config={baseConfig} data={page.puckData} />
</LayoutWrapper>
Avoiding Double Headers/Footers

When your host app already provides a global header/footer via its root layout (e.g., Next.js layout.tsx), use createRenderLayouts() to strip them from Puck layouts:

tsimport { HybridPageRenderer, createRenderLayouts } from '@delmaredigital/payload-puck/render'
import { siteLayouts } from '@/lib/puck-layouts'

// Strip header/footer for rendering (host app layout provides them)
const renderLayouts = createRenderLayouts(siteLayouts)

export function PageRenderer({ page }) {
  const layout = renderLayouts.find(l => l.value === page.puckData?.root?.props?.pageLayout)

  return (
    <LayoutWrapper layout={layout}>
      <HybridPageRenderer page={page} config={baseConfig} />
    </LayoutWrapper>
  )
}

This pattern keeps header/footer in your editor layouts for realistic preview, but avoids double headers when rendering.

Dark Mode Support

The Puck editor automatically detects PayloadCMS dark mode and applies CSS overrides to ensure visibility. It also provides a preview toggle to test how pages look in both light and dark modes.

How It Works

  1. Editor UI: Automatically detects dark mode via .dark class (PayloadCMS) or prefers-color-scheme (OS preference), then injects Puck CSS variable overrides
  2. Preview Iframe: A sun/moon toggle lets you switch the preview content between light and dark modes independently from the editor UI

Configuration

Dark mode is enabled by default. You can customize via props on PuckEditor:

ts<PuckEditor
  autoDetectDarkMode={true}           // Auto-detect PayloadCMS dark mode (default: true)
  showPreviewDarkModeToggle={true}    // Show light/dark toggle in header (default: true)
  initialPreviewDarkMode={false}      // Start preview in light mode (default: false)
/>
Using Components Directly

For custom editor implementations:

tsimport {
  DarkModeStyles,
  PreviewModeToggle,
  useDarkMode,
} from '@delmaredigital/payload-puck/editor'

function CustomEditor() {
  const { isDarkMode, source } = useDarkMode()
  const [previewDark, setPreviewDark] = useState(false)

  return (
    <>
      <DarkModeStyles />
      <PreviewModeToggle
        isDarkMode={previewDark}
        onToggle={setPreviewDark}
      />
      <Puck ... />
    </>
  )
}
Detecting Theme in Puck Components

If your Puck components need to dynamically adjust JavaScript-controlled styles based on the preview theme, use the usePuckPreviewTheme() hook:

tsimport { usePuckPreviewTheme } from '@delmaredigital/payload-puck/editor'
import { useEffect, useState } from 'react'

function useDetectTheme() {
  const puckTheme = usePuckPreviewTheme()

  // For frontend (non-editor), read from DOM
  const [domTheme, setDomTheme] = useState(() =>
    typeof document !== 'undefined'
      ? document.documentElement.getAttribute('data-theme') === 'dark'
      : false
  )

  // In editor: use context. On frontend: use DOM.
  return puckTheme !== null ? puckTheme : domTheme
}

CSS dark mode variants (like Tailwind's dark: classes) work automatically via the data-theme attribute. However, if you need to conditionally render different JavaScript values (like overlay colors), those won't update reactively when the preview toggle changes. The context provides reactive updates.

Page-Tree Integration

When @delmaredigital/payload-page-tree is detected, the plugin automatically adds folder management to the Puck sidebar.

How It Works

The plugin checks if your collection has a pageSegment field (page-tree's signature). When detected:

  1. Folder Picker — Select a folder from the hierarchy
  2. Page Segment — Edit the page's URL segment
  3. Slug Preview — See the computed slug (folder path + segment)

Plugin Configuration

tscreatePuckPlugin({
  // Auto-detect (default)
  pageTreeIntegration: undefined,

  // Explicitly enable with custom config
  pageTreeIntegration: {
    folderSlug: 'payload-folders',
    pageSegmentFieldName: 'pageSegment',
  },

  // Explicitly disable
  pageTreeIntegration: false,
})

Custom Editor UI

For custom editor implementations outside Payload admin, use the hasPageTree prop:

tsimport { PuckEditor } from '@delmaredigital/payload-puck/client'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'

<PuckEditor
  config={editorConfig}
  pageId={page.id}
  initialData={page.puckData}
  pageTitle={page.title}
  pageSlug={page.slug}
  apiEndpoint="/api/puck/pages"
  hasPageTree={true}
  folder={page.folder}
  pageSegment={page.pageSegment}
/>

Performance: Detection is instant — it reads the in-memory collection config, no database queries.

Hybrid Integration

Add Puck to existing collections with legacy blocks.

Automatic (Recommended)

If you already have a pages collection, the plugin adds only the Puck-specific fields:

ts// payload.config.ts
export default buildConfig({
  collections: [
    {
      slug: 'pages',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'layout', type: 'blocks', blocks: [HeroBlock, CTABlock] },
      ],
    },
  ],
  plugins: [
    createPuckPlugin({ pagesCollection: 'pages' }),
  ],
})

The editorVersion field auto-detects whether pages use legacy blocks or Puck.

Manual with getPuckCollectionConfig() (recommended)

When you need the isHomepage field, use getPuckCollectionConfig() which returns both fields AND hooks:

tsimport { getPuckCollectionConfig } from '@delmaredigital/payload-puck'

const { fields: puckFields, hooks: puckHooks } = getPuckCollectionConfig({
  includeSEO: true,
  includeEditorVersion: true,
  includePageLayout: true,
  includeIsHomepage: true,
})

export const Pages: CollectionConfig = {
  slug: 'pages',
  hooks: {
    beforeChange: [
      ...(puckHooks.beforeChange ?? []),
    ],
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'layout', type: 'blocks', blocks: [...] },
    ...puckFields,
  ],
}
Manual with getPuckFields() (fields only)

If you don't need isHomepage or want to configure hooks manually:

tsimport { getPuckFields, createIsHomepageUniqueHook } from '@delmaredigital/payload-puck'

export const Pages: CollectionConfig = {
  slug: 'pages',
  hooks: {
    beforeChange: [createIsHomepageUniqueHook()],
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'layout', type: 'blocks', blocks: [...] },
    ...getPuckFields({
      includeSEO: true,
      includeEditorVersion: true,
      includePageLayout: true,
      includeIsHomepage: true,
    }),
  ],
}

Rendering Hybrid Pages

tsimport { HybridPageRenderer } from '@delmaredigital/payload-puck/render'
import { LegacyBlockRenderer } from '@/components/LegacyBlockRenderer'

<HybridPageRenderer
  page={page}
  config={puckConfig}
  legacyRenderer={(blocks) => <LegacyBlockRenderer blocks={blocks} />}
/>

AI Integration

Early Preview: While Puck's AI features are powerful, this plugin's implementation is still in early stages and under active development. Expect changes as we refine the integration.

The plugin integrates with Puck AI to enable AI-assisted page generation. Users can describe what they want in natural language, and the AI builds complete page layouts using your components.

Requirements

  • PUCK_API_KEY environment variable (from Puck Cloud)
  • AI features require @puckeditor/plugin-ai and @puckeditor/cloud-client (bundled with the plugin)

Quick Start

Enable AI in your plugin configuration:

tscreatePuckPlugin({
  pagesCollection: 'pages',
  ai: {
    enabled: true,
    context: 'We are Acme Corp, a B2B SaaS company. Use professional language.',
  },
})

This automatically registers the AI chat endpoint, adds the AI chat plugin to the editor, and applies comprehensive component instructions for better generation quality.

Dynamic Business Context

Instead of hardcoding context in your config, you can manage it through Payload admin:

tscreatePuckPlugin({
  ai: {
    enabled: true,
    contextCollection: true,  // Creates puck-ai-context collection
  },
})

This creates a puck-ai-context collection where you can add entries for brand guidelines, tone of voice, product information, industry context, technical requirements, and page patterns. Context entries can be enabled/disabled and ordered.

Context Editor Plugin: When contextCollection: true, a "Context" panel appears in the Puck plugin rail. Users can view, create, edit, and toggle context entries directly in the editor.

Prompt Management

Store reusable prompts in Payload:

tscreatePuckPlugin({
  ai: {
    enabled: true,
    promptsCollection: true,  // Creates puck-ai-prompts collection
    examplePrompts: [
      { label: 'Landing page', prompt: 'Create a landing page for...' },
    ],
  },
})

Prompts from the collection appear in the AI chat interface. A "Prompts" panel in the plugin rail allows in-editor prompt management.

Custom Tools

Enable the AI to query your data:

tsimport { z } from 'zod'

createPuckPlugin({
  ai: {
    enabled: true,
    tools: {
      getProducts: {
        description: 'Get products from the database',
        inputSchema: z.object({ category: z.string() }),
        mode: 'preload',
        execute: async ({ category }, { payload }) => {
          return await payload.find({
            collection: 'products',
            where: { category: { equals: category } },
          })
        },
      },
    },
  },
})

Tools receive a context object with the Payload instance and authenticated user. Properties: description, inputSchema (Zod), outputSchema (optional), mode ("auto" | "preload" | "inline"), and execute.

Usage Tracking with onFinish
tscreatePuckPlugin({
  ai: {
    enabled: true,
    onFinish: ({ totalCost, tokenUsage }) => {
      console.log(`Cost: $${totalCost}`)
      console.log(`Tokens: ${tokenUsage.totalTokens}`)
      // tokenUsage also includes: inputTokens, outputTokens,
      // reasoningTokens, cachedInputTokens
    },
  },
})
Request Interception with prepareRequest

Modify outgoing AI requests before they're sent:

ts<PuckEditor
  enableAi={true}
  aiOptions={{
    prepareRequest: (opts) => ({
      ...opts,
      headers: { ...opts.headers, 'X-Tenant-ID': tenantId },
    }),
    scrollTracking: true,
  }}
/>

AI Configuration Options

OptionDefaultDescription
enabledfalseEnable AI features
contextundefinedStatic system context for the AI
contextCollectionfalseCreate puck-ai-context collection for dynamic context
promptsCollectionfalseCreate puck-ai-prompts collection for reusable prompts
examplePrompts[]Static example prompts for the chat interface
toolsundefinedCustom tools for AI to query your system
componentInstructionsundefinedOverride default component AI instructions
onFinishundefinedCallback with cost and token usage after generation
Component Instructions

The plugin includes comprehensive instructions for all built-in components. To customize or extend:

tscreatePuckPlugin({
  ai: {
    enabled: true,
    componentInstructions: {
      Heading: {
        ai: { instructions: 'Use our brand voice: professional but approachable' },
        fields: {
          text: {
            ai: {
              instructions: 'Keep under 8 words',
              bind: 'heading',
              stream: true,
            },
          },
        },
      },
      // Exclude a component from AI generation entirely
      InternalWidget: {
        ai: { exclude: true },
      },
      // Configure default zone behavior
      Section: {
        ai: {
          defaultZone: {
            allow: ['Text', 'Heading', 'Button'],
            disallow: ['Section'],
          },
        },
      },
    },
  },
})

Component-level AI options: instructions, exclude, defaultZone (with allow, disallow, disabled).

Field-level AI options: instructions, required, exclude, bind, stream.

Standalone API Routes

For custom implementations outside the plugin:

ts// app/api/puck/[...all]/route.ts
import { createPuckAiApiRoutes } from '@delmaredigital/payload-puck/ai'
import config from '@payload-config'

export const POST = createPuckAiApiRoutes({
  payloadConfig: config,
  auth: {
    authenticate: async (request) => {
      return { user: { id: '...' } }
    },
  },
  ai: {
    context: 'Your business context...',
  },
})
AI Exports
tsimport {
  // Plugins
  createAiPlugin,
  createPromptEditorPlugin,
  createContextEditorPlugin,

  // Hooks
  useAiPrompts,
  useAiContext,

  // Config utilities
  injectAiConfig,
  comprehensiveComponentAiConfig,
  pagePatternSystemContext,

  // API routes
  createPuckAiApiRoutes,
  createAiGenerate,
} from '@delmaredigital/payload-puck/ai'

Plugin Order

When using autoGenerateCollection: true (the default) with @delmaredigital/payload-page-tree, plugin order matters.

The Issue

The page-tree plugin validates configured collections when it initializes. If Puck hasn't created the collection yet, page-tree won't see it and will skip adding its fields (folder relationships, slug generation, etc.).

Correct Order

ts// CORRECT: Puck creates the collection before page-tree runs
export const plugins = [
  createPuckPlugin({ pagesCollection: 'pages' }),  // Creates Pages first
  pageTreePlugin({ collections: ['pages'] }),      // Now sees Pages
]

// WRONG: page-tree runs before Pages exists
export const plugins = [
  pageTreePlugin({ collections: ['pages'] }),      // Pages doesn't exist!
  createPuckPlugin({ pagesCollection: 'pages' }),  // Creates Pages too late
]
When Order Doesn't Matter

If you define your collection manually (with autoGenerateCollection: false), order doesn't matter because the collection already exists in your config:

tsexport default buildConfig({
  collections: [Pages],  // Collection exists before plugins run
  plugins: [
    pageTreePlugin({ collections: ['pages'] }),
    createPuckPlugin({ pagesCollection: 'pages', autoGenerateCollection: false }),
  ],
})

Plugin Options

OptionDefaultDescription
pagesCollection'pages'Collection slug to use for pages
autoGenerateCollectiontrueCreate the collection if it doesn't exist, or add Puck fields to existing
enableEndpointstrueRegister API endpoints at /api/puck/:collection for the editor
enableAdminViewtrueRegister the Puck editor view in Payload admin
adminViewPath'/puck-editor'Path for the editor (full: /admin/puck-editor/:collection/:id)
pageTreeIntegrationauto-detectIntegration with @delmaredigital/payload-page-tree
layoutsundefinedLayout definitions for page templates
editorStylesheetundefinedPath to CSS file for editor iframe styling
editorStylesheetCompiledundefinedPath to pre-compiled CSS for production
editorStylesheetUrls[]Additional stylesheet URLs for the editor (e.g., Google Fonts)
previewUrlundefinedURL for "View" button — string or function receiving page data
Preview URL examples
ts// Simple static URL pattern
createPuckPlugin({
  previewUrl: '/preview',
})

// Dynamic prefix based on page data
createPuckPlugin({
  previewUrl: (page) => `/${page.slug || ''}`,
})

// Organization-scoped pages (multi-tenant)
createPuckPlugin({
  previewUrl: (page) => {
    const orgSlug = page.organization?.slug || 'default'
    return (slug) => slug ? `/${orgSlug}/${slug}` : `/${orgSlug}`
  },
})

When previewUrl is a function, the page document is fetched with depth: 1 so relationship fields are populated.

Editor Stylesheet

The Puck editor renders page content in an iframe. By default, this iframe doesn't have access to your frontend's CSS (Tailwind utilities, CSS variables, fonts). The editorStylesheet option solves this by compiling and serving your CSS.

Development (Runtime Compilation)

In development, CSS is compiled at runtime for hot reload support:

tscreatePuckPlugin({
  pagesCollection: 'pages',
  editorStylesheet: 'src/app/(frontend)/globals.css',
  editorStylesheetUrls: [
    'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
  ],
})

The plugin creates an endpoint at /api/puck/styles. On first request, the CSS is compiled with PostCSS/Tailwind and cached. The iframe loads this compiled CSS.

Production (Build-Time Compilation)

Runtime compilation fails on serverless platforms (Vercel, Netlify, etc.) because source CSS files aren't deployed. Use withPuckCSS() to compile CSS at build time:

Step 1: Wrap your Next.js config

js// next.config.js
import { withPuckCSS } from '@delmaredigital/payload-puck/next'
import { withPayload } from '@payloadcms/next/withPayload'

const nextConfig = {
  // your config...
}

export default withPuckCSS({
  cssInput: 'src/app/(frontend)/globals.css',
})(withPayload(nextConfig))

Step 2: Add the compiled path to your plugin config

tscreatePuckPlugin({
  pagesCollection: 'pages',
  editorStylesheet: 'src/app/(frontend)/globals.css',      // For dev (runtime)
  editorStylesheetCompiled: '/puck-editor-styles.css',     // For prod (static)
  editorStylesheetUrls: [
    'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
  ],
})
OptionDefaultDescription
cssInput(required)Path to source CSS file
cssOutput'puck-editor-styles.css'Output filename in public/
skipInDevtrueSkip compilation in development

Requirements: postcss must be installed in your project. For Tailwind v4: @tailwindcss/postcss. For Tailwind v3: tailwindcss.

Custom API Routes

The built-in endpoints handle most use cases. Only disable them if you need custom authentication or middleware. Three route factories are available:

FactoryRoute PatternMethods
createPuckApiRoutes/api/puck/[collection]GET (list), POST (create)
createPuckApiRoutesWithId/api/puck/[collection]/[id]GET, PATCH, DELETE
createPuckApiRoutesVersions/api/puck/[collection]/[id]/versionsGET, POST (restore)

See the JSDoc in @delmaredigital/payload-puck/api for usage examples.

Export Reference

Export PathDescription
@delmaredigital/payload-puckPlugin creation, field utilities
@delmaredigital/payload-puck/plugincreatePuckPlugin
@delmaredigital/payload-puck/configbaseConfig, createConfig(), extendConfig()
@delmaredigital/payload-puck/config/editoreditorConfig for editing
@delmaredigital/payload-puck/clientPuckEditor, PuckConfigProvider, page-tree utilities
@delmaredigital/payload-puck/editorPuckEditor, HeaderActions, editor hooks
@delmaredigital/payload-puck/rscPuckEditorView for Payload admin views
@delmaredigital/payload-puck/renderPageRenderer, HybridPageRenderer
@delmaredigital/payload-puck/fieldsCustom Puck fields and CSS helpers
@delmaredigital/payload-puck/componentsComponent configs for custom configurations
@delmaredigital/payload-puck/themeThemeProvider, theme utilities
@delmaredigital/payload-puck/layoutsLayout definitions, LayoutWrapper
@delmaredigital/payload-puck/apiAPI route factories (for custom implementations)
@delmaredigital/payload-puck/aiAI plugins, hooks, config utilities, API routes
@delmaredigital/payload-puck/nextwithPuckCSS Next.js config wrapper for build-time CSS
@delmaredigital/payload-puck/admin/clientEditWithPuckButton, EditWithPuckCell

MIT License — @delmaredigital/payload-puck