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.
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:
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.)
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
/>
)
}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.
// 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>
)
}'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.
// 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 }),
},
})// 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' },
],
})// 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:
export { POST } from '../versions/route'Layouts
Header and footer components, editor preview settings and sticky header padding. See Layouts for every option.
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
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.
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:
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.
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--primary: #18181b;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #18181b;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--radius: 0.5rem;
}
/* Applied when the .dark class is on an ancestor */
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #18181b;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--destructive: #7f1d1d;
--destructive-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #d4d4d8;
--popover: #0a0a0a;
--popover-foreground: #fafafa;
--card: #0a0a0a;
--card-foreground: #fafafa;
}Add this to your main CSS file after @import "tailwindcss", so Puck components are included in the build and use your theme:
@source "../node_modules/@delmaredigital/payload-puck";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
