Skip to Content
Delmare Digital
Guides

Starter files

Copy-paste starter files for frontend routes, a standalone editor, custom API routes, layouts, theme and a custom component.

These are starting points to copy into your app and adapt. They come from the examples/ folder of the plugin repository (the folder is not part of the npm package, so copy from GitHub or from this page). Each file has comments marking what to customise.

You don't need all of them. With the built-in admin editor and endpoints, a working setup is the plugin, a provider and one frontend route, as in the quick start. Reach for the rest when you want a standalone editor outside the Payload admin, your own API routes, or custom layouts, themes and components.

payload.config.ts
page.tsx

Plugin configuration

Merge puckConfig.plugins into your existing buildConfig() call in payload.config.ts, alongside your existing plugins. Keep your app's db, secret, admin and other settings:

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

// Merge this fragment's plugins into your existing buildConfig({...}).
// Keep your existing secret, db, admin and other configuration.
export const puckConfig = {
  plugins: [
    createPuckPlugin({
      // Collection slug for pages (default: 'pages')
      pagesCollection: 'pages',

      // Generate the Pages collection (default: true)
      // Set to false to define your own collection
      autoGenerateCollection: true,

      // Optional: override parts of the generated collection
      collectionOverrides: {
        admin: {
          defaultColumns: ['title', 'slug', 'updatedAt'],
        },
      },

      // Optional: access control (defaults: published-only anonymous reads,
      // authenticated users can read everything and write)
      access: {
        read: ({ req }) => req.user ? true : { _status: { equals: 'published' } },
        create: ({ req }) => !!req.user,
        update: ({ req }) => !!req.user,
        delete: ({ req }) => !!req.user,
      },

      // Optional: map Puck root props to Payload fields.
      // Must match rootPropsMapping in your custom API routes for load/save parity.
      rootPropsMapping: [
        { from: 'pageLayout', to: 'pageLayout' },
      ],

      // Optional: options for the Page Layout field.
      // Only value/label matter here; header/footer components
      // live in lib/puck-layouts.ts
      layouts: [
        { value: 'default', label: 'Default' },
        { value: 'landing', label: 'Landing' },
        { value: 'full-width', label: 'Full Width' },
      ],

      // Admin UI
      admin: {
        buttonLabel: 'Edit with Puck',
      },
    }),
  ],
  // Drafts are enabled on the pages collection automatically
} satisfies Pick<Config, 'plugins'>

To open a standalone editor (below) from the admin instead of the built-in view, set admin.editorPathPattern: '/pages/{id}/edit'. That option is deprecated and exists only for custom external editors.

Frontend routes

The homepage gets its own route; a catch-all handles every other path. (The quick start's [[...slug]] route does both in one file.)

src/app/(frontend)/page.tsx
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import config from '@payload-config'
import { PageRenderer } from '@delmaredigital/payload-puck/render'
import { baseConfig } from '@delmaredigital/payload-puck/config'
import { DEFAULT_LAYOUTS } from '@delmaredigital/payload-puck/layouts'
// import { siteLayouts } from '@/lib/puck-layouts'
import type { Data as PuckData } from '@puckeditor/core'
import type { Metadata } from 'next'

async function getHomepage() {
  const payload = await getPayload({ config })
  const { docs } = await payload.find({
    collection: 'pages',
    where: {
      and: [
        { _status: { equals: 'published' } },
        { or: [{ isHomepage: { equals: true } }, { slug: { equals: 'home' } }] },
      ],
    },
    limit: 1,
  })
  return docs[0] ?? null
}

export async function generateMetadata(): Promise<Metadata> {
  const page = await getHomepage()
  if (!page) return { title: 'Home' }
  return {
    title: page.meta?.title || page.title,
    description: page.meta?.description,
  }
}

export default async function HomePage() {
  const page = await getHomepage()
  if (!page) notFound()

  // Handle pages without content
  if (!page.puckData) {
    return (
      <div className="container mx-auto py-12 text-center">
        <h1 className="text-2xl font-bold mb-4">{page.title}</h1>
        <p className="text-muted-foreground">
          This page has no content yet. Edit it in the admin panel.
        </p>
      </div>
    )
  }

  // PageRenderer applies the page's layout (header, footer, width)
  return (
    <PageRenderer
      data={page.puckData as PuckData}
      config={baseConfig}
      layouts={DEFAULT_LAYOUTS} // Replace with: siteLayouts
    />
  )
}
src/app/(frontend)/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import config from '@payload-config'
import { PageRenderer } from '@delmaredigital/payload-puck/render'
import { baseConfig } from '@delmaredigital/payload-puck/config'
import { DEFAULT_LAYOUTS } from '@delmaredigital/payload-puck/layouts'
// import { siteLayouts } from '@/lib/puck-layouts'
import type { Data as PuckData } from '@puckeditor/core'
import type { Metadata } from 'next'

type Params = Promise<{ slug: string[] }>

async function getPage(slug: string[]) {
  const payload = await getPayload({ config })
  const { docs } = await payload.find({
    collection: 'pages',
    where: {
      and: [
        { _status: { equals: 'published' } },
        { slug: { equals: slug.join('/') } },
      ],
    },
    limit: 1,
  })
  return docs[0] ?? null
}

export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
  const page = await getPage((await params).slug)
  if (!page) return {}
  return {
    title: page.meta?.title || page.title,
    description: page.meta?.description,
    robots: page.meta?.noindex ? { index: false } : undefined,
  }
}

export default async function Page({ params }: { params: Params }) {
  const page = await getPage((await params).slug)
  if (!page) notFound()

  if (!page.puckData) {
    return (
      <div className="container mx-auto py-12 text-center">
        <h1 className="text-2xl font-bold mb-4">{page.title}</h1>
        <p className="text-muted-foreground">
          This page has no content yet. Edit it in the admin panel.
        </p>
      </div>
    )
  }

  return (
    <PageRenderer
      data={page.puckData as PuckData}
      config={baseConfig}
      layouts={DEFAULT_LAYOUTS} // Replace with: siteLayouts
    />
  )
}

To apply a theme, wrap PageRenderer in ThemeProvider.

Standalone editor

An editor page outside the Payload admin, with save and publish, version history (when the versions route exists), an unsaved-changes warning, preview in a new tab, and header and footer preview from your layouts.

The (manage) layout must import your Tailwind CSS, or the editor UI is completely unstyled. Adjust the import path to your globals.css.

src/app/(manage)/layout.tsx
// Import your Tailwind CSS; adjust the path as needed
import '@/app/(frontend)/globals.css'

export const metadata = {
  title: 'Puck Editor',
  description: 'Visual page editor',
}

export default function ManageLayout({ children }: { children: React.ReactNode }) {
  return (
    // data-theme="light" is needed if your CSS hides content until a theme
    // is set (a common FOUC-prevention pattern). Match your theme system.
    <html lang="en" data-theme="light">
      <body>{children}</body>
    </html>
  )
}
src/app/(manage)/pages/[id]/edit/page.tsx
'use client'

import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { PuckEditor } from '@delmaredigital/payload-puck/editor'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'
import { DEFAULT_LAYOUTS } from '@delmaredigital/payload-puck/layouts'
// import { siteLayouts } from '@/lib/puck-layouts'
// import { puckTheme } from '@/lib/puck-theme'
import type { Data } from '@puckeditor/core'

interface PageData {
  id: string
  title: string
  slug: string
  puckData: Data
  _status?: 'draft' | 'published'
}

export default function EditPagePage() {
  const params = useParams()
  const pageId = params.id as string

  const [page, setPage] = useState<PageData | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    async function fetchPage() {
      try {
        setLoading(true)
        const response = await fetch(`/api/puck/pages/${pageId}`)
        if (!response.ok) {
          const data = await response.json()
          throw new Error(data.error || 'Failed to fetch page')
        }
        const data = await response.json()
        setPage(data.doc)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred')
      } finally {
        setLoading(false)
      }
    }
    if (pageId) fetchPage()
  }, [pageId])

  if (loading) return <p className="p-8 text-muted-foreground">Loading page...</p>

  if (error || !page) {
    return (
      <div className="p-8">
        <p className="text-destructive mb-4">{error ?? 'Page not found'}</p>
        <Link href="/admin/collections/pages" className="text-primary underline">
          Back to Pages
        </Link>
      </div>
    )
  }

  // Default Puck data if none exists
  const initialData: Data = page.puckData || {
    root: { props: { title: page.title || 'New Page' } },
    content: [],
    zones: {},
  }

  return (
    <PuckEditor
      pageId={page.id}
      initialData={initialData}
      config={editorConfig}
      pageTitle={page.title}
      pageSlug={page.slug}
      apiEndpoint="/api/puck/pages"
      backUrl={`/admin/collections/pages/${page.id}`}
      previewUrl={(slug: string) => `/${slug}`}
      initialStatus={page._status}
      // Layouts control header/footer preview and editor backgrounds
      layouts={DEFAULT_LAYOUTS} // Replace with: siteLayouts
      // theme={puckTheme}
      onSaveSuccess={(data: Data) => console.log('Page saved:', data)}
      onSaveError={(err: Error) => console.error('Failed to save page:', err)}
    />
  )
}

API routes

Only needed if you replace the built-in endpoints (enableEndpoints: false); see Custom API routes. Each route authenticates with payload.auth() and passes Payload's user unchanged as payloadUser for collection access checks, with a string-ID copy as user for route gating. The canX hooks are optional extra gates.

src/app/api/puck/pages/route.ts
// GET: list pages · POST: create a page
import { createPuckApiRoutes } from '@delmaredigital/payload-puck/api'
import { getPayload } from 'payload'
import config from '@payload-config'

export const { GET, POST } = createPuckApiRoutes({
  collection: 'pages',
  payloadConfig: config,
  auth: {
    authenticate: async (request) => {
      const payload = await getPayload({ config })
      const { user } = await payload.auth({ headers: request.headers })
      if (!user) return { authenticated: false }
      return {
        authenticated: true,
        user: { ...user, id: String(user.id) },
        payloadUser: user,
      }
    },
    // Optional: who can list pages
    canList: async (user) => ({ allowed: true }),
    // Optional: who can create pages
    canCreate: async (user) => ({ allowed: !!user }),
  },
})
src/app/api/puck/pages/[id]/route.ts
// GET: one page · PATCH: update (draft/publish) · DELETE: delete
import { createPuckApiRoutesWithId } from '@delmaredigital/payload-puck/api'
import { getPayload } from 'payload'
import config from '@payload-config'

export const { GET, PATCH, DELETE } = createPuckApiRoutesWithId({
  collection: 'pages',
  payloadConfig: config,
  auth: {
    authenticate: async (request) => {
      const payload = await getPayload({ config })
      const { user } = await payload.auth({ headers: request.headers })
      if (!user) return { authenticated: false }
      return {
        authenticated: true,
        user: { ...user, id: String(user.id) },
        payloadUser: user,
      }
    },
    canView: async (user, pageId) => ({ allowed: true }),
    canEdit: async (user, pageId) => ({ allowed: !!user }),
    // Defaults to canEdit
    canPublish: async (user, pageId) => ({ allowed: !!user }),
    // Example: only admins can delete
    canDelete: async (user, pageId) => ({ allowed: user?.role === 'admin' }),
  },

  // Optional: map Puck root props to Payload fields.
  // Must match createPuckPlugin({ rootPropsMapping }) for load/save parity.
  rootPropsMapping: [
    { from: 'pageLayout', to: 'pageLayout' },
  ],
})
src/app/api/puck/pages/[id]/versions/route.ts
// GET: list versions. POST is re-exported by the restore route below.
import { createPuckApiRoutesVersions } from '@delmaredigital/payload-puck/api'
import { getPayload } from 'payload'
import config from '@payload-config'

export const { GET, POST } = createPuckApiRoutesVersions({
  collection: 'pages',
  payloadConfig: config,
  auth: {
    authenticate: async (request) => {
      const payload = await getPayload({ config })
      const { user } = await payload.auth({ headers: request.headers })
      if (!user) return { authenticated: false }
      return {
        authenticated: true,
        user: { ...user, id: String(user.id) },
        payloadUser: user,
      }
    },
    // Optional: who can view versions
    canView: async (user, pageId) => ({ allowed: !!user }),
    // Optional: who can restore versions
    canEdit: async (user, pageId) => ({ allowed: !!user }),
  },
})

The History panel posts to /restore, so add this route as well:

src/app/api/puck/pages/[id]/restore/route.ts
export { POST } from '../versions/route'

Layouts

Header and footer components, editor preview settings and sticky header padding. See Layouts for every option.

src/lib/puck-layouts.ts
import {
  DEFAULT_LAYOUTS,
  createLayout,
  mergeLayouts,
  type LayoutDefinition,
} from '@delmaredigital/payload-puck/layouts'
// import { Header } from '@/components/header'
// import { Footer } from '@/components/footer'

// Default layout with a sticky header.
// stickyHeaderHeight adds that much top padding, in the editor AND on the
// frontend, so content doesn't render behind the header.
// stickyFooter defaults to true: the footer stays at the bottom of the
// viewport even with little content. Set false to let it flow.
export const defaultWithHeader = createLayout({
  value: 'default',
  label: 'Default',
  description: 'Standard page layout with header and footer',
  classes: { wrapper: '', container: 'mx-auto px-4 sm:px-6 lg:px-8', content: '' },
  maxWidth: '1200px',
  fullWidth: false,
  // header: Header,
  // footer: Footer,
  editorBackground: '#ffffff',
  editorDarkMode: false,
  stickyHeaderHeight: 80,
})

// Landing pages: no header/footer, full control of the page
export const landingLayout = createLayout({
  value: 'landing',
  label: 'Landing',
  description: 'Full-width layout without header/footer',
  fullWidth: true,
  editorBackground: '#f8fafc',
})

// Dark layout: editorDarkMode sets the dark mode class on the iframe
export const darkLayout = createLayout({
  value: 'dark',
  label: 'Dark',
  description: 'Dark theme layout',
  classes: { wrapper: 'bg-gray-900', container: 'mx-auto px-4 sm:px-6 lg:px-8', content: '' },
  maxWidth: '1200px',
  editorBackground: '#111827',
  editorDarkMode: true,
  stickyHeaderHeight: 80,
})

// Narrow, reading-optimised layout
export const blogLayout = createLayout({
  value: 'blog',
  label: 'Blog Post',
  description: 'Narrow layout optimized for long-form reading',
  classes: { container: 'mx-auto px-4 sm:px-6', content: 'prose prose-lg max-w-none' },
  maxWidth: '720px',
  stickyHeaderHeight: 80,
})

// Full-width sections with a data attribute for page-type styling
export const marketingLayout = createLayout({
  value: 'marketing',
  label: 'Marketing',
  description: 'Full-width sections with contained content areas',
  fullWidth: true,
  dataAttributes: { 'data-page-type': 'marketing' },
  stickyHeaderHeight: 80,
})

// replace: true returns only these layouts instead of merging with the defaults
export const siteLayouts: LayoutDefinition[] = mergeLayouts(
  DEFAULT_LAYOUTS,
  [defaultWithHeader, landingLayout, darkLayout, blogLayout, marketingLayout],
  { replace: true },
)

Use siteLayouts in PuckConfigProvider (or PuckEditor), in PageRenderer, and optionally in the plugin's layouts option. createConfig(siteLayouts) from /config builds a render config whose Page Layout options come from the same list.

Theme

src/lib/puck-theme.ts
import type { ThemeConfig } from '@delmaredigital/payload-puck/theme'

// Uses shadcn/ui CSS variable conventions: --primary, --secondary,
// --accent, --muted, --destructive, --background, --foreground, --input, --ring
export const puckTheme: ThemeConfig = {
  buttonVariants: {
    default: { classes: 'bg-primary text-primary-foreground hover:bg-primary/90' },
    secondary: { classes: 'bg-secondary text-secondary-foreground hover:bg-secondary/80' },
    outline: { classes: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground' },
    ghost: { classes: 'hover:bg-accent hover:text-accent-foreground' },
    destructive: { classes: 'bg-destructive text-destructive-foreground hover:bg-destructive/90' },
    link: { classes: 'text-primary underline-offset-4 hover:underline' },
  },

  focusRingColor: 'focus:ring-ring',

  // Colour picker presets: add your brand colours
  colorPresets: [
    { hex: '#ffffff', label: 'White' },
    { hex: '#f8fafc', label: 'Background' },
    { hex: '#f1f5f9', label: 'Secondary' },
    { hex: '#e2e8f0', label: 'Muted' },
    { hex: '#64748b', label: 'Muted FG' },
    { hex: '#334155', label: 'Slate 700' },
    { hex: '#1e293b', label: 'Primary' },
    { hex: '#0f172a', label: 'Foreground' },
    { hex: '#000000', label: 'Black' },
    // { hex: '#3b82f6', label: 'Brand Blue' },
  ],
}

Pass it to PuckConfigProvider (or PuckEditor) as theme, and wrap the frontend renderer in <ThemeProvider theme={puckTheme}>.

Custom component

A banner with variants, colour pickers and padding. See Custom components.

src/components/CustomBanner.tsx
import type { ComponentConfig } from '@puckeditor/core'
import {
  createColorPickerField,
  createPaddingField,
  colorValueToCSS,
  paddingValueToCSS,
  cn,
  type ColorValue,
  type PaddingValue,
} from '@delmaredigital/payload-puck/fields'

export interface CustomBannerProps {
  variant: 'info' | 'success' | 'warning' | 'error'
  title: string
  message: string
  showIcon: boolean
  backgroundColor: ColorValue | null // overrides the variant default
  textColor: ColorValue | null       // overrides the variant default
  padding: PaddingValue | null
}

const variantStyles: Record<string, { bg: string; text: string; border: string }> = {
  info: { bg: 'bg-blue-50', text: 'text-blue-800', border: 'border-blue-200' },
  success: { bg: 'bg-green-50', text: 'text-green-800', border: 'border-green-200' },
  warning: { bg: 'bg-amber-50', text: 'text-amber-800', border: 'border-amber-200' },
  error: { bg: 'bg-red-50', text: 'text-red-800', border: 'border-red-200' },
}

export const CustomBannerConfig: ComponentConfig<CustomBannerProps> = {
  label: 'Banner',

  fields: {
    variant: {
      type: 'select',
      label: 'Variant',
      options: [
        { label: 'Info', value: 'info' },
        { label: 'Success', value: 'success' },
        { label: 'Warning', value: 'warning' },
        { label: 'Error', value: 'error' },
      ],
    },
    title: { type: 'text', label: 'Title' },
    message: { type: 'textarea', label: 'Message' },
    showIcon: {
      type: 'radio',
      label: 'Show Icon',
      options: [
        { label: 'Yes', value: true },
        { label: 'No', value: false },
      ],
    },
    // Colour picker with hex input, opacity slider and theme presets
    backgroundColor: createColorPickerField({ label: 'Background Color', showOpacity: true }),
    textColor: createColorPickerField({ label: 'Text Color', showOpacity: false }),
    // Four sides, link/unlink toggle, unit selector
    padding: createPaddingField({ label: 'Padding', showUnits: true }),
  },

  defaultProps: {
    variant: 'info',
    title: 'Information',
    message: 'This is an informational banner message.',
    showIcon: true,
    backgroundColor: null, // null means "use the variant default"
    textColor: null,
    padding: { top: 16, right: 16, bottom: 16, left: 16, unit: 'px', linked: true },
  },

  render: ({ variant, title, message, backgroundColor, textColor, padding }) => {
    const styles = variantStyles[variant] || variantStyles.info

    // Each helper returns undefined for an empty value
    const customBg = colorValueToCSS(backgroundColor)
    const customText = colorValueToCSS(textColor)

    return (
      <div
        role="alert"
        className={cn(
          'relative rounded-lg border',
          !customBg && styles.bg,
          !customText && styles.text,
          styles.border,
        )}
        style={{
          backgroundColor: customBg,
          color: customText,
          padding: paddingValueToCSS(padding),
        }}
      >
        {title && <h4 className="font-semibold text-sm">{title}</h4>}
        {message && <p className={cn('text-sm', title && 'mt-1')}>{message}</p>}
      </div>
    )
  },
}

Custom config

Add the banner to the built-in components:

src/puck/custom-config.ts
import { extendConfig, editorConfig } from '@delmaredigital/payload-puck/config/editor'
import { CustomBannerConfig } from '@/components/CustomBanner'

export const customConfig = extendConfig({
  // Start from the full payload-puck editor configuration
  base: editorConfig,

  // The key becomes the component's identifier in the editor
  components: {
    Banner: CustomBannerConfig,
  },

  // Categories are the collapsible sections of Puck's component list.
  // For an existing category, components are appended.
  categories: {
    interactive: {
      title: 'Interactive',
      components: ['Banner'], // added next to Button, Card, Divider, Accordion
    },
    // marketing: { title: 'Marketing', components: ['Hero'], defaultExpanded: false },
  },

  // Remove built-in components you don't need
  exclude: [
    // 'Template',
  ],

  // Optional: page-level root fields
  // root: {
  //   fields: { headerStyle: { type: 'select', label: 'Header Style', options: [...] } },
  //   defaultProps: { headerStyle: 'default' },
  // },
})

Pass customConfig to PuckConfigProvider. For rendering, build the matching config from baseConfig with extendConfig from /config; see Extending configs.

Theme CSS variables

If you don't use shadcn/ui, copy this file and import it in your root layout or globals.css, then change the colours to your brand. If you use shadcn/ui, these variables already exist.

Edit this page on GitHub

On this page