10 KiB
PayloadCMS — Migration, Troubleshooting, Hierarchy & Integrations
Overview
Payload follows semver. Major bumps (v2→v3, v3→v4) include breaking changes with published migration guides. v4 ships a @payloadcms/codemod CLI for most auto-migratable changes.
Migration Guide: v2 → v3
Core Shift
Admin Panel moved from React Router SPA to Next.js App Router (RSC). Express is removed; req/res are now Web API Request/Response.
Installation Steps
# Install new packages (all must share exact versions)
pnpm i next react react-dom payload @payloadcms/ui @payloadcms/next
# Remove deprecated packages
pnpm remove express nodemon @payloadcms/bundler-webpack @payloadcms/bundler-vite
Run DB migrations if on Postgres or MongoDB — see release tags v3.0.0-beta.39 (pg) and v3.0.0-beta.131 (mongo).
Key Breaking Changes (v2 → v3)
| Area | Change |
|---|---|
| Bundler | Remove admin.bundler — Next.js handles bundling now |
| Secret | Move secret from payload.init() → payload.config.ts top-level |
| Env vars | PAYLOAD_PUBLIC_* → NEXT_PUBLIC_* for client-side env vars |
| Request | req.headers['content-type'] → req.headers.get('content-type') |
| CSS | Remove admin.css / admin.scss; use (payload)/custom.scss or a Custom Provider |
| Favicon | admin.favicon → admin.icons: [{ path, sizes }] |
| OG Image | admin.meta.ogImage → admin.meta.openGraph.images |
| Routes | admin.logoutRoute / admin.inactivityRoute → admin.routes.logout / .inactivity |
custom |
Top-level custom is now server-only; use admin.custom for client+server |
afterError hook |
Now an array of functions; args expanded with req, res |
| Static dir | upload.staticDir must be an absolute path |
upload.staticURL |
Removed — use generateFileURL instead |
Custom Components — Critical:
// Import paths moved from payload → @payloadcms/ui
- import { TextField, useField } from 'payload'
+ import { TextField, useField } from '@payloadcms/ui'
// Component definitions: direct imports → file paths
- Button: MyComponent
+ Button: '/src/components/Logout#MyComponent'
// All custom components are server-rendered by default
// Add 'use client' when you need state/hooks
Endpoints — New Response API:
- handler: (req, res) => { res.json({ ... }) }
+ handler: (req) => { return Response.json({ ... }) }
// params → req.routeParams; body → req.data (must call addDataAndFileToRequest)
React Hooks:
- useTitle() from 'payload'
+ const { title } = useDocumentInfo() // from '@payloadcms/ui'
- useConfig() → SanitizedConfig
+ const { config } = useConfig() // now ClientConfig
- useTranslation('general')
+ useTranslation() // use full keys: t('general:cancel')
Plugins — all now named exports with Plugin suffix:
- import seo from '@payloadcms/plugin-seo'
+ import { seoPlugin } from '@payloadcms/plugin-seo'
Cloud Storage — standalone packages replace sub-path adapters:
- import { s3Adapter } from '@payloadcms/plugin-cloud-storage/s3'
+ import { s3Storage } from '@payloadcms/storage-s3'
Reserved field names — avoid: file, _id (mongo), salt, hash, password, email, filename, url, sizes, _order, _path, _locale.
Migration Guide: v3 → v4
Codemod (run first)
npx @payloadcms/codemod
Idempotent — safe to run on partially-migrated projects.
Key Breaking Changes (v3 → v4)
List View Select API — now always-on; remove admin.enableListViewSelectAPI: true.
Globals admin.components shape:
admin: {
components: {
- elements: {
- SaveButton: '/path/to/CustomSaveButton',
- Description: '/path/to/CustomDescription',
- },
+ Description: '/path/to/CustomDescription',
+ edit: {
+ SaveButton: '/path/to/CustomSaveButton',
+ },
},
}
forceSelect → select function:
- forceSelect: { title: true, slug: true }
+ select: ({ select }) => (select ? { ...select, title: true, slug: true } : undefined)
admin.hideAPIURL removed — use view tab condition:
admin.components.views.edit.api.tab.condition = () => false
Import sources consolidated (@payloadcms/ui re-exports removed):
- import { Column, ListPreferences } from '@payloadcms/ui'
+ import type { Column, CollectionPreferences } from 'payload'
- import { headersWithCors } from '@payloadcms/next/utilities'
+ import { headersWithCors } from 'payload'
Document title split out:
- const { title } = useDocumentInfo()
+ const { title } = useDocumentTitle() // from '@payloadcms/ui'
Troubleshooting Common Errors
TypeError: Cannot destructure property 'config' of...
Cause: Duplicate or mismatched versions of payload / @payloadcms/* / react / react-dom.
Diagnose:
pnpm why @payloadcms/ui
# or manually:
find node_modules -name package.json -exec grep -H '"name": "@payloadcms/ui"' {} \;
Fix sequence:
- Pin exact versions (remove
^and~) forpayload,@payloadcms/*,react,react-dom - Delete
node_modules/ pnpm install- If persists:
pnpm store prune+ delete lockfile +pnpm install pnpm dedupe
Deep imports rule: Inside the Payload Admin Panel, only import from:
@payloadcms/ui@payloadcms/ui/rsc@payloadcms/ui/shared
Never @payloadcms/ui/elements/Button (use that only in your own frontend).
useUploadHandlers must be used within UploadHandlersProvider
Cause: Monorepo with mismatched next or react versions across packages.
Fix: Install payload, @payloadcms/*, next, react, react-dom at monorepo root to prevent hoisting duplicates.
Unauthorized, you must be logged in after login
Auth cookie set but rejected. Check:
- CORS: avoid
'*'; explicitly whitelist your domain - CSRF: whitelist your domain if configured
- Cookie settings: no misconfigured
domain
Inspect: DevTools → Network → login request → check for Set-Cookie header and any browser ⚠️ icon.
Database connection error: Cannot read properties of undefined (reading 'searchParams')
Special characters in DB password must be URI-encoded:
const encodedPassword = encodeURIComponent(process.env.DB_PASSWORD)
// Use encodedPassword in your connection string
HMR WebSocket fails with --experimental-https
# .env
USE_HTTPS=true
# or for fully custom URL:
PAYLOAD_HMR_URL_OVERRIDE=wss://localhost:3000/_next/webpack-hmr
Hierarchy Feature
Adds automatic parent-child tree management to any collection.
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [{ name: 'title', type: 'text', required: true }],
hierarchy: {
parentFieldName: 'parent', // auto-created if absent
},
}
Auto-generates virtual fields (not stored in DB):
_h_slugPath— URL-safe breadcrumb path_h_titlePath— human-readable breadcrumb
Opt-in path computation:
// Local API
payload.findByID({ collection: 'pages', id, context: { computeHierarchyPaths: true } })
// REST API
GET /api/pages/abc123?computeHierarchyPaths=true
// Or via select
payload.findByID({ collection: 'pages', id, select: { _h_slugPath: true } })
Performance: Each document requires one extra query for ancestors; ancestors are request-scoped cached. Only enable when displaying breadcrumbs/URLs.
Polymorphic hierarchy (posts under pages):
hierarchy: {
parentFieldName: 'parent',
relationTo: ['pages', 'posts'],
}
Limitations:
locale: 'all'skips hierarchy processing — update locales individually- Parent field is not localized — tree structure is shared across all locales
- Circular references throw an error automatically
Vercel Content Link Integration
Enterprise-only. Embeds Content Source Maps into API responses via @payloadcms/plugin-csm.
npm i @payloadcms/plugin-csm
import contentSourceMaps from '@payloadcms/plugin-csm'
buildConfig({
plugins: [contentSourceMaps({ collections: ['pages'] })],
})
Enable encoding only in draft/preview (performance):
// REST
fetch(`/api/pages?encodeSourceMaps=true&where[slug][equals]=${slug}`)
// Local API
payload.find({ collection: 'pages', context: { encodeSourceMaps: true } })
Troubleshooting:
datefields not encoded by default — usevercelStegaSplit(text)to separate encoded datablocks/arrayfields get_encodedSourceMapproperty; usedata-vercel-edit-targetattribute on wrapper
Examples — Quickstart Templates
npx create-payload-app --example <name>
Available examples: auth, custom-components, draft-preview, email, form-builder, live-preview, multi-tenant, tailwind-shadcn-ui, whitelabel
Full list: github.com/payloadcms/payload/tree/main/examples
Gotchas
- Yarn 1.x is not supported — use pnpm (recommended), npm, or Yarn >=2.
v3removesexpressentirely — anyreq.paramsaccess must becomereq.routeParams.admin.components.viewskeys are now camelCase:Edit→edit,Default→default.Fieldstype renamed toFormStatein v3.BlockField→BlocksFieldin v3.- In monorepos, install shared dependencies at root to avoid multiple instances.