3.0 to 4.0 Migration Guide
All breaking changes are listed below. If you encounter changes that are not explicitly listed here, please consider contributing to this documentation by submitting a PR.
Codemod
Most of the breaking changes below are auto-migratable using the @payloadcms/codemod CLI:
The codemod is idempotent and safe to run on a partially-migrated project.
Breaking Changes
Payload request creation is consolidated
Payload request creation now uses createPayloadRequest for local contexts and createPayloadRequestFromWebRequest to convert a Web Request. Admin context setup is exposed as initAdminContext.
Old name | New name |
|---|---|
| |
| |
| |
| |
| |
| |
| |
| |
initAdminContext and its cache types are primarily contracts for framework adapters. Custom Root layout adapters must rename their injected initReq callback to initAdminContext.
Local request creation now takes a single object argument containing payload. For a request without options:
CreatePayloadRequestArgs is exported from payload and requires payload. Options-only values should use Omit<CreatePayloadRequestArgs, 'payload'>. The old CreateLocalReqOptions type was declared in the internal request utility, not exported from the package root; relative/private imports of that type require manual migration. The codemod handles an existing named import of that type only when its source is payload.
Web request conversion uses its new role-specific name:
Codemod
To migrate automatically, there's a codemod for this change available by running:
Automatic call migration requires a plain identifier payload argument and either {} or an initialized local const options identifier. The options type must expose all seven old option properties, or be any. For that identifier form, the generated argument captures payload first and uses forwarding getters for context, depth, fallbackLocale, locale, req, urlSuffix, and user. This preserves inherited getters, their read order, and rejected promises from throwing getters without reading unrelated properties. Complex expressions, mutable/unresolved options bindings, narrow option types, collisions, and unsupported type usages are left unchanged with notes for manual migration.
JWT authentication is versioned without moving token data
Payload 4 keeps the decoded JWT payload flat, so integrations can continue reading id, collection, sid, email, and fields selected by saveToJWT from the same paths as Payload 3.
Payload now reserves id, collection, email, sid, iat, and exp. A saveToJWT alias using one of those names is ignored, and Payload writes the trusted value after collecting fields configured with saveToJWT. This prevents an alias such as saveToJWT: 'id' or saveToJWT: 'collection' from changing which user or collection the signed token authenticates as.
The token's signed protected header now contains authVersion: 1. This marker identifies tokens issued by the fixed authentication format. It lives in the header, rather than the payload, because earlier configurations could alias a field to any payload key. Payload rejects tokens that do not contain the supported header version.
Tokens issued by a patched Payload 3 release already contain the same authVersion: 1 protected-header marker and remain valid after upgrading to Payload 4, provided the signing secrets and authentication collection configuration remain compatible.
Tokens issued by an unpatched Payload release do not contain the marker and are rejected. There is no safe compatibility fallback because a legacy token might have been signed after its trusted identity fields were overwritten. When upgrading directly from an unpatched release:
- Users must sign in again after deployment.
- Integrations must reissue stored bearer tokens.
- Expect a temporary spike in
401responses until clients discard their stored tokens.
Raw-token consumers do not need to change token data paths, and /me returns the same data for a valid new token. External validators and integrations that mint Payload-compatible JWTs must set and check the protected-header version. Import Payload's shared version instead of hardcoding it:
A token is also rejected if its collection is not auth-enabled or has disableLocalStrategy enabled.
See Token Data for the full token data shape.
Local API access is now enforced by default
overrideAccess now defaults to false on Local API operations, so omitting it respects Access Control instead. Operations that do not expose overrideAccess, and operations such as resetPassword where it was already required or did not default to true, are unaffected by this change.
overrideAccess: false(the new default) — respect Access Control. Use this whenever the operation acts on behalf of a user, and passuseralongside it.overrideAccess: true— bypass Access Control. Use this for trusted server-side work such as cron jobs, seeding, and migrations.
The property stays optional on the affected operations, and there is no compile error for either TypeScript or JavaScript projects — a call that omitted overrideAccess before still compiles after upgrading, it just behaves differently: it now enforces Access Control instead of skipping it. Run the codemod to find every affected call site and restore the previous behavior explicitly:
Review the results afterwards. Leave the property off, or set it to overrideAccess: false, wherever the operation acts on behalf of a user — that is the more secure outcome and is now the default.
Cases the codemod leaves behind
Run a typecheck after the codemod. Three patterns it cannot resolve:
- Spread arguments —
payload.find({ ...args }). The codemod cannot tell whetherargsalready supplies the property. - Aliased or cast receivers —
const db = payload; db.find({ ... })and(payload as any).find({ ... }). It matchespayload,.payload, and their.jobsproperties only. - Non-literal arguments —
payload.find(options). The codemod cannot safely modify the value at the call site.
Jobs operations use the same default
payload.jobs.queue, run, runByID, cancel, and cancelByID now also default overrideAccess to false. The option remains optional, but omitting it runs the corresponding jobs.access.queue, jobs.access.run, or jobs.access.cancel function. The codemod adds overrideAccess: true to these calls—including zero-argument payload.jobs.run() calls—to preserve Payload 3 behavior for review.
On-demand validation adds a validate operation
The Operation type now includes validate. The operation argument for collection, global, and field beforeValidate and beforeChange hooks also includes validate.
On-demand validation runs these hooks without saving the candidate. Update exhaustive operation checks and skip external side effects that should only run during create or update:
Sanitized collection and global access types now require a validate key. Payload supplies this key during configuration sanitization and uses update access by default. Code that constructs a sanitized access object directly must add the key.
Access Control callbacks now receive document slugs
Collection and Global Access Control callback arguments now include the document's slug. This additive property is unlikely to affect most projects, but code that manually invokes access callbacks or executeAccess must now pass slug. Code that validates, enumerates, or compares the exact callback argument shape may also need updating.
Custom bin scripts replaced by native CLI commands
The config.bin API has been removed. Custom commands are now registered through config.cli.commands and use the same schema-backed CLI API as Payload's built-in commands.
In Payload 3.x, config entries connected a command name to a script path:
The imported module had to expose a specially named script function:
In Payload 4.0, define a real CLI command instead:
Register the command export in your Payload config:
When migrating custom scripts:
- Replace
binentries with entries in thecli.commandsmap. The map key becomes the command name. - Replace the exported
script(config)function with a schema-backed command created bydefineCLICommand. - Register commands by import path to keep them out of the Payload config's module graph. Paths use the existing
PayloadComponentsyntax, resolve relative topayload.config.ts, and support default or named exports. DirectCLICommandvalues are also accepted. - Define the handler's semantic input with a Standard Schema implementation such as Zod. Supported top-level properties become options automatically; use
clionly to mark positional arguments, hide fields, or override unusual shell syntax. - Use the provided
getPayload()function so Payload can shut down database connections after the command finishes. - Return an exit code from the handler when needed. Do not call
process.exit()from the command.
Payload adds built-in command import references to cli.commands while sanitizing the config, then applies project entries over those defaults. All built-ins are named exports from payload/cli/builtin, and Payload imports a referenced module only once even when it provides several commands. The sanitized map therefore contains the complete local CLI, and built-in and custom commands use the same loader. Custom commands appear in payload --help and receive Standard Schema validation and generated shell help. A command with the same map key as a built-in command replaces it, while false disables that command. Set cli: false to disable the entire Payload CLI. Command names are case-sensitive. Because custom commands must be registered before the CLI parses input, the Payload config is now loaded before displaying root CLI help.
See the Payload CLI documentation for the complete API.
Legacy migration CLI export removed
The undocumented migrateCLI export has been removed. It accepted an already-sanitized config, a minimist-shaped parsedArgs object, and a special migrationDir override. This duplicated migration command registration and kept the old argument format alive after the main CLI moved to Commander.
If application code needs migration behavior rather than command-line parsing, call the database adapter directly through an initialized Payload instance:
The adapter also exposes the corresponding migrateDown, migrateFresh, migrateRefresh, migrateReset, migrateStatus, and createMigration methods.
If you need to execute the real CLI from JavaScript, use the existing generic payload.bin() method. It starts the same CLI process a user would run in a terminal and returns its exit code:
The spawned CLI discovers the Payload config from cwd or PAYLOAD_CONFIG_PATH. Configure a custom migration directory through your database adapter instead of passing it as a programmatic CLI override.
Generator helpers now return result objects
generateTypes now returns { types, outputFile, written } instead of returning the generated source directly when using returnString: true. generateImportMap now returns { outputFile, written }, and payload.db.generateSchema() returns { outputFile }. Code that ignores these return values does not need to change.
Default query depth has been reduced
Queries that omit depth now populate one relationship level instead of two. This reduces database work and response size by default.
If your application relies on two populated levels, prefer specifying depth: 2 only on the queries that need it:
To preserve the Payload 3.x behavior application-wide, set defaultDepth: 2:
One place inherits defaultDepth without naming it: when localization is enabled, @payloadcms/plugin-search refetches the document without an explicit depth before handing originalDoc to your beforeSync hook. Read only first-level relationships there, or refetch yourself with the depth you need.
Versions are now enabled by default for all collections and globals
Collections and globals now have versions: true by default (maxPerDoc/max: 100, drafts disabled). In previous versions you had to opt in; you now opt out.
What changes:
- Any collection or global without an explicit
versionsproperty will automatically get a_<slug>_versionstable or collection in the database. You will need to run a database migration to add these new tables for existing projects. - Auth collections such as Users do not get versions by default — the built-in user collection already ships with
versions: false, and you should do the same for any custom auth collection. - The bare
versions: trueyou may have added previously is now redundant and can be removed.
To opt out for an existing collection or global:
Two codemods are available to automate this migration:
Run migrate-versions-default if you want zero schema changes — it adds versions: false to every CollectionConfig and GlobalConfig that does not already set versions. If you want to selectively keep versions on some configs, run the codemod and then delete versions: false from the ones you want to enable. Each config you opt into requires a database migration.
Run remove-versions-true to clean up any bare versions: true that is now the default.
Version read access inherits collection and global read access
When readVersions is not configured, version access now inherits the Collection or Global read Access Control instead of requiring an authenticated user by default.
This means a public read function can make historical and unpublished versions publicly readable, while a query constraint returned by read is also applied to version documents. If you want to preserve the previous authenticated-only behavior, configure readVersions explicitly:
Authorship is now enabled by default
Collections and globals now track authorship by default: Payload injects createdBy and updatedBy polymorphic relationship fields (related to every auth-enabled collection) and stamps them from req.user on each write. Previously there was no authorship tracking; you now opt out with authorship: false.
What changes:
- Every non-internal collection and global gains
createdByandupdatedBywhen your config has at least one auth collection. On SQL adapters this adds relationship rows (stored in each entity's_relstable), so existing projects need a database migration. In development the schema is pushed automatically; in production, generate a migration withpayload migrate:createand runpayload migrate. Existing documents are backfilled withnull. createdByis only derived on a document's first write, so editing a document whosecreatedByisnull(for example a migrated or anonymously created document) does not backfill the editor as the creator.- Payload's internal system collections and globals are excluded automatically:
payload-jobs,payload-jobs-stats,payload-preferences,payload-locked-documents,payload-migrations,payload-query-presets, and the KV collection.
Plugins: plugin-injected collections also receive authorship unless the plugin opts out. In this release:
- Opted out (no authorship):
@payloadcms/plugin-search,@payloadcms/payload-cloud, and all@payloadcms/plugin-ecommercecollections. - Kept on (they gain
createdBy/updatedBy):@payloadcms/plugin-redirects,@payloadcms/plugin-form-builder(forms and form submissions), and@payloadcms/plugin-import-export(imports and exports). Existing projects using these plugins on a SQL adapter need a migration for those collections too. To opt one out, setauthorship: falsethrough that plugin's collection-override option.
To opt out for an existing collection or global:
A codemod is available to preserve the previous behaviour:
migrate-authorship-default adds authorship: false to every CollectionConfig and GlobalConfig in your source that does not already set authorship, so your own collections have zero schema changes. It only rewrites your config files — it cannot reach plugin-injected collections, so any plugin that keeps authorship on (above) still gains the fields and requires a migration (or an explicit authorship: false override).
List View Select API is now the default
The admin.enableListViewSelectAPI Collection Config property has been removed. The List View now always uses the Select API to query only the active columns, which was previously opt-in.
If you previously set admin.enableListViewSelectAPI: true, remove the property — the behavior is now default. The migrate-list-view-select-api transform in @payloadcms/codemod handles this automatically.
Global component slots now match Collections
Global configs now use the same admin.components shape as Collections. The Edit View slot container is renamed from elements to edit, the Description slot is hoisted to top-level admin.components.Description, and editMenuItems is now supported.
The available slots under admin.components.edit for Globals now match Collections (beforeDocumentControls, editMenuItems, PreviewButton, PublishButton, SaveButton, SaveDraftButton, Status, UnpublishButton). Upload remains Collection-only.
Globals also now support custom views with arbitrary keys under admin.components.views, mirroring Collections.
This change is auto-migrated by the globals-components-edit codemod.
Dynamic selection replaces static forced selection
The static forceSelect config has been removed. Each Collection and Global now accepts a top-level select function that receives the current operation, req, and the caller's select, and returns the final select to use. Returning undefined leaves the caller's select unchanged.
The new function form replaces the caller's select rather than deep-merging into it. To preserve the previous behavior of always forcing certain fields, spread the caller's select into the returned object yourself.
The same change applies to Globals.
Run npx @payloadcms/codemod --transform migrate-force-select to migrate automatically. The codemod handles object-literal forceSelect values, including nested ones (which previously deep-merged) — those are rewritten to call deepMergeSimple from payload/shared, with the import added automatically. Non-literal values, configs that already define a sibling select, and unsupported member kinds are surfaced as notes for manual review.
API tab visibility now uses a condition
admin.hideAPIURL has been removed from Collections and Globals. Use the existing admin.components.views.edit.api.tab.condition to hide the API tab instead.
Run npx @payloadcms/codemod --transform remove-hide-api-url to migrate automatically.
Aliased UI and Next.js re-exports have been removed
Several types and utilities that were re-exported from @payloadcms/ui and @payloadcms/next/utilities for backwards compatibility have been removed. The canonical exports live in payload and payload/shared; import directly from there.
Pass-through re-exports — same name, new source:
Symbol | Old source | New source |
|---|---|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
Renamed types — use the new canonical name from payload:
Old name | New name | Source |
|---|---|---|
| | |
| | |
| | |
Running npx @payloadcms/codemod --transform migrate-aliased-exports preserves old local bindings by importing renamed symbols with an as alias (e.g. import type { CollectionPreferences as ListPreferences } from 'payload' or import { createPayloadRequestFromWebRequest as createPayloadRequest } from 'payload'). Run migrate-payload-request-creation to complete the request helper's identifier and call-site migration, or drop the other aliases and rename their usages manually if you want to fully commit to the new names.
Field component types replaced by props types
Field component aliases that wrapped props in React.ComponentType<Props> have been removed. Import the props type directly so each component can use React.FC<Props> or a function declaration.
Generic type arguments move to the props type:
Function declarations can annotate their parameter without a component wrapper:
This covers main field components, labels, descriptions, errors, diffs, and block row labels. Admin-view types such as AdminViewComponent and DocumentTabComponent are separate.
Codemod
To migrate automatically, there's a codemod for this change available by running:
Diff component props comparisonValue and versionValue renamed to valueFrom and valueTo
Custom Diff Components (admin.components.Diff) receive the two field values being compared as valueFrom and valueTo. The comparisonValue and versionValue props have been removed from FieldDiffClientProps and FieldDiffServerProps, including every field-specific variant such as TextFieldDiffClientProps.
Old prop | New prop | Description |
|---|---|---|
| | Field value from the version being compared from |
| | Field value from the version being compared to |
Codemod
To migrate automatically, there's a codemod for this change available by running:
The codemod updates functions whose props are typed with a Diff props or component type imported from payload (e.g. TextFieldDiffClientProps), or with a type in the same file built from one. It renames destructured props and reads such as props.comparisonValue. Untyped components and props passed on to other components (e.g. <OtherDiff comparisonValue={...} />) aren't changed automatically; the codemod prints a note for each leftover in files that import a Diff type.
Default value functions can now return promises (#17522)
The exported DefaultValue type's function variant previously declared a synchronous-only return type (SerializableValue), even though Payload has always awaited the result of a defaultValue function at runtime. The type now correctly reflects this and allows Promise<SerializableValue> | SerializableValue.
What changes:
- Field-level
defaultValuefunctions can now be typed asasyncwithout a TypeScript error. This was always safe at runtime — only the type was too strict. - If you import
DefaultValueto type your own function or variable and then call it synchronously assuming the result is never aPromise, you'll need to update that code to handle thePromisebranch.
If you only use DefaultValue to type a defaultValue property passed into a Collection, Global, or Field config, no changes are needed.
Sanitized collection and auth config types now match runtime defaults
SanitizedCollectionConfig and its auth type no longer use DeepRequired. This reduces TypeScript checker work and makes optionality match runtime defaults.
Properties Payload does not default, such as auth.cookies.domain, auth.useAPIKey, and auth.depth, now require undefined handling:
Properties sanitization does fill, including standard access functions, hook arrays, cookie defaults, and normalized auth options, are now required. Use CollectionConfig or IncomingAuthType for input and SanitizedCollectionConfig after sanitization.
Sanitized root config types now match runtime defaults
SanitizedConfig no longer uses DeepRequired. This reduces TypeScript checker work and preserves optionality for nested values Payload does not populate, such as admin.autoLogin, graphQL.mutations, queryPresets, and typescript.postProcess.
Code that consumes these properties from payload.config may now need to handle undefined:
Properties filled during sanitization, including auth.jwtOrder, root routes, GraphQL defaults, TypeScript output settings, and admin dashboard defaults, remain required. Config.auth.jwtOrder is now optional for incoming configs because Payload supplies its documented default. The unused SanitizedConfig.paths property was also removed because it is not present at runtime.
Sanitized global config types now match runtime defaults
SanitizedGlobalConfig no longer uses DeepRequired. This reduces TypeScript checker work and preserves optionality for values global sanitization does not populate, such as admin.components, graphQL, lockDocuments, and typescript.
Code that consumes these properties may now need to handle undefined:
Properties filled during sanitization, including _sanitized, admin, custom, label, standard access functions, hook arrays, endpoints, and flattened fields, remain required. Use GlobalConfig for input and SanitizedGlobalConfig after sanitization.
User types have been consolidated
Payload's user types have been consolidated so each one has a clear job. This completes a cleanup already planned in 3.x: UntypedUser was deprecated for removal in 4.0, and TypedUser was marked to be renamed to User in 4.0.
Previously, the public User type was an alias of the loose UntypedUser, while TypedUser was the generated type for auth-enabled collections. ClientUser was also loose, and the runtime auth fields _strategy and _sid were not typed on req.user.
There are now two types:
User— the generated type for a user of an auth-enabled collection (whatTypedUserused to be). Without generated types, it falls back to a documented shape containing Payload's built-in auth fields.AuthenticatedUser—Userplus the optional runtime auth fields_strategyand_sid. This is whatreq.user,payload.auth(), auth strategies,meresponses, anduseAuth().userreturn.
AuthenticatedUser is intentionally shared by server and client auth APIs. It describes the signed-in identity, not an exact serialization or sanitization contract: runtime, hidden, access-controlled, or unselected fields can be absent from individual responses. Use User for other stored or read user documents, including user documents rendered in client code.
_sid is flow-dependent: request-authenticated users can carry it, while standard login, me, and refresh responses omit it.
The collection property remains available on authenticated users and in client auth responses. Removing ClientUser does not change runtime serialization.
What changes:
TypedUseris removed — replace it withUser.UntypedUseris removed — useUserfor a user document orAuthenticatedUserfor the signed-in user.ClientUseris removed — replace it withAuthenticatedUserforuseAuth().user,meresponses, and other client auth APIs.UserandAuthenticatedUserno longer have a{ [key: string]: any }index signature. Custom auth-collection fields require generated types or an explicit augmented type or cast.req.userandpayload.auth().userare now typed asAuthenticatedUser, so_strategyand_sidtype-check.- The deprecated top-level
strategyfield is removed frommeandrefreshresponses (REST, GraphQL, and the SDK). Readuser._strategyinstead. @payloadcms/plugin-ecommerce'sClientUserWithCartis removed — replace it withUserWithCart.MeOperationResult.userandrefreshCookieAsync()can benull, matching their existing runtime behavior.refreshCookieAsync()also preserves the custom user type passed touseAuth<T>().- Collection
admin.hiddencallbacks now receivePayloadRequest['user'], includingnullwhen there is no authenticated user. - The Local API
useroption is nowUser | nullinstead of the looseDocumenttype for collectioncount,create,delete,duplicate,find,findByID,findDistinct, andupdate; collection and global version operations; and globalfindOneandupdate. UserSession.createdAtis now optional and nullable (createdAt?: Date | null | string) to match the generated session shape. Callers must handle a missing value.
AuthenticatedUser is assignable to User, so passing req.user to Local API operations continues to work. Passing an arbitrary object now type-errors.
Use User for stored or read user documents. Use AuthenticatedUser when code specifically receives the signed-in user from req.user, payload.auth(), an auth strategy, a me response, or useAuth().
For plugin authors: because User no longer has an index signature, reading a field your plugin adds to the user collection off req.user (or User) no longer type-checks. Declare an augmented type and cast req.user to it — this is what the first-party plugins now do (e.g. @payloadcms/plugin-ecommerce for its cart join field):
If the field name is dynamic (configurable at runtime), index through Record<string, unknown> instead:
Plugins now infer generated types automatically
The <ConfigType> type parameter on multiTenantPlugin and searchPlugin — and on MultiTenantPluginConfig / SearchPluginConfig — has been removed. It only existed to override the user / locale types, which Payload now infers from your generated types automatically: userHasAccessToAllTenants receives the generated User, and search's skipSync callback receives TypedLocale | undefined.
Drop the type argument — your callbacks are typed correctly without it.
Plugin definitions now require a slug and named options
The experimental definePlugin helper now requires a slug and passes plugin options through a named options argument:
slugis now required. It registers the plugin in thepluginsmap for cross-plugin discovery. Add one to everydefinePlugincall.- Options are a named
optionsargument. They were previously spread into theplugincallback's args alongsideconfigandplugins; they're now under a singleoptionsproperty.TOptionsis also no longer constrained toRecord<string, unknown>, sointerfaceand generic option types work.
Admin routing is now framework-agnostic
@payloadcms/ui no longer depends on next directly. Custom admin components (field components, custom views, plugins) that previously imported router hooks or the Link component from next/* must now import the framework-agnostic equivalents from @payloadcms/ui. Behavior is identical when running on Next.js — the NextRouterAdapter shipped by @payloadcms/next wires next/navigation hooks and next/link into the same context — but ui code can now also run on non-Next adapters (e.g. TanStack Start) without modification.
Hooks — import from @payloadcms/ui instead of next/navigation:
Old import | New import |
|---|---|
| |
| |
| |
| |
Link component — use PayloadLink from @payloadcms/ui:
Types — LinkProps from next/link is replaced by LinkAdapterProps from payload:
LinkAdapterProps is a framework-neutral shape: href: string, optional prefetch / replace / scroll / ref, plus standard AnchorHTMLAttributes<HTMLAnchorElement> (minus href). The Next adapter forwards these to next/link.
RouterAdapterRouter shape — the object returned by useRouter() now has a stable, framework-agnostic surface:
If you were relying on Next-specific extras like prefetch() on the router instance, switch to the Link component's prefetch prop instead.
Custom apps embedding RootProvider — RootProvider now requires a RouterAdapter prop. Apps using the default Next.js admin layout get this wired automatically. If you assemble RootProvider yourself, pass the framework's adapter component:
Next.js subpath exports have moved to the UI package
The ./client, ./rsc, and ./templates subpath exports on @payloadcms/next were thin re-exports of components that now live in @payloadcms/ui. All admin elements (DocumentHeader, FormHeader, HierarchyTypeField, Logo, Nav) and templates (DefaultTemplate, MinimalTemplate) have been relocated to @payloadcms/ui and no longer depend on next/* modules. Import them directly from the canonical source.
Symbol | Old source | New source |
|---|---|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
If you reference any of these components by string path in your Payload config (e.g. Component: '@payloadcms/next/rsc#CollectionCards'), update the path string too — then regenerate the import map with payload generate:importmap.
Run npx @payloadcms/codemod --transform migrate-next-subpath-exports to migrate automatically. It rewrites both import declarations and string component paths.
Document title state has moved to its own hook
For performance reasons, the document title state has been split out of DocumentInfoContext into its own DocumentTitleContext. Access it through the useDocumentTitle hook, which exposes the same title and setDocumentTitle API. Components that subscribed to useDocumentInfo solely for the title will no longer re-render when unrelated document state changes.
If you only used useDocumentInfo for title / setDocumentTitle, drop the import entirely. If you used it for other properties as well, leave the original useDocumentInfo() call in place and add a separate useDocumentTitle() call alongside it.
Run npx @payloadcms/codemod --transform migrate-document-title-context to migrate automatically. The codemod splits mixed destructures, removes the useDocumentInfo import when no other properties are still used, and preserves any rename aliases (e.g. { title: docTitle }).
Locale hooks can now return null
The return type of useLocale is now Locale | null. It returns null when localization is disabled or configured without any locales. In Payload 3, the hook was typed as Locale and returned an empty object when localization was unavailable.
Custom Client Components that read locale properties directly must handle the nullable value:
When a missing locale is already valid for the receiving API, optional chaining is sufficient:
<<<<<<< HEAD
readOnly removed from the useField return type
The deprecated readOnly property has been removed from FieldType, the object returned by useField. useField already stopped returning it in 3.x, so it was always undefined at runtime.
Read readOnly from your field component's props instead:
RichText adapter i18n property removed
=======
Rich text adapters no longer expose translations
> > > > > > 7dd8d28c6a (cleans up headings that contained code blocks, updates titles on migration guides)
The i18n property on RichTextAdapter (and its return type from adapter provider functions) has been removed. It was deprecated in v3 in favour of merging translations directly into config.i18n.translations inside the adapter provider.
If you authored a custom rich-text adapter that returned an i18n object, move that logic into the adapter provider function and merge the translations into the config yourself:
Config and rich-text sanitization are now synchronous
Config and field sanitizers now return their result directly instead of returning a promise. This affects projects that call sanitizeConfig, sanitizeField, or sanitizeFields from payload directly:
The same change applies to sanitizeServerEditorConfig and the methods on editorConfigFactory from @payloadcms/richtext-lexical. Remove await, and replace any .then() or .catch() usage with a direct call and try/catch.
The optional richTextSanitizationPromises argument on the field sanitizers has been renamed to richTextSanitizers. Its callbacks are now synchronous:
Custom rich-text adapter providers and Lexical server feature callbacks must also return their value synchronously:
If an affected adapter or feature performs asynchronous work, move that work to an asynchronous plugin before config sanitization or to a runtime hook. Projects that only use buildConfig(...) and lexicalEditor(...) normally are not affected; buildConfig remains asynchronous.
Transaction requests now use a single identifier property
The transactionIDPromise property on PayloadRequest has been removed. It was deprecated and unused — transactionID (which already supports Promise<number | string>) is the correct property to use for both resolved values and pending transactions.
<<<<<<< HEAD
headers and languageCode removed from AdminContext
The headers and languageCode properties have been removed from AdminContext, the object returned by initAdminContext. Both duplicated values that are already available on req:
req.i18n.language is typed as string. Cast it to AcceptedLanguages if you need the narrower type.
languageCode has also been removed from PartialAdminContext. Use i18n.language instead. getRootLayoutData from @payloadcms/ui no longer accepts headers or languageCode and reads both from req.
Codemod
To migrate automatically, run:
The codemod rewrites property reads and destructures on initAdminContext() results and on parameters or variables typed as AdminContext or PartialAdminContext. It also updates Pick<AdminContext, ...> and AdminContext['...'] types and drops both arguments from getRootLayoutData() calls. Element access such as context['headers'], and contexts passed through your own wrapper functions, are left unchanged for manual migration.
DiffMethod type removed
The deprecated DiffMethod type has been removed from payload. It mirrored an enum from react-diff-viewer-continued, which Payload no longer depends on. Remove any imports of DiffMethod.
diffMethod prop removed from Diff components
Custom Diff components (admin.components.Diff) no longer receive a diffMethod prop. It has been removed from FieldDiffClientProps and every field-specific variant like TextFieldDiffClientProps. Payload always passed the same 'diffWordsWithSpace' value, so the prop carried no information. Each Diff component handles its own diffing.
Remove diffMethod from your props. If you passed it on to a diffing utility, pass 'diffWordsWithSpace' directly instead:
Collection afterOperation hook: operation: 'read' removed
=======
Collection after-operation hooks no longer report read operations
> > > > > > 7dd8d28c6a (cleans up headings that contained code blocks, updates titles on migration guides)
The deprecated 'read' value for the operation argument of collection afterOperation hooks has been removed. Payload has not dispatched afterOperation with operation: 'read' for some time — the find and findByID operations report their own names ('find' and 'findByID') instead.
If a custom afterOperation hook branches on operation === 'read', split it to handle 'find' and 'findByID':
'find' resolves the paginated result (result.docs), while 'findByID' resolves a single document — narrow on the specific operation if your logic depends on the result shape.
Run npx @payloadcms/codemod --transform migrate-after-operation-read to migrate inline hooks automatically. The codemod rewrites operation === 'read' (and !==/==/!=) comparisons against a destructured, aliased, or property-accessed operation argument. Non-inline hooks (hooks referenced by name) and switch statements with a 'read' case are surfaced as notes for manual review.
The operation: 'read' value on the beforeOperation hook is unchanged.
Static configuration defaults have been removed (#17103)
The defaults object exported from payload has been removed. It was deprecated because it was a single shared object and mutating it (or any config that used it as a base) leaked changes into every other consumer of the defaults.
If you depended on reading a specific default value at runtime, read it from the sanitized config returned by buildConfig (or payload.config) instead of from the static defaults object.
Alternatively, you can call addDefaultsToConfig to get the unsanitized config but populated with the default properties.
Import and export field callbacks have moved to hooks
The toCSV and fromCSV field options in custom['plugin-import-export'] have been removed. Use hooks.beforeExport and hooks.beforeImport instead — they work for both CSV and JSON formats.
The argument shapes differ slightly from the old API:
Old ( | New ( |
|---|---|
| |
| |
| (removed — was alias for |
— | |
The fromCSV → hooks.beforeImport change is non-breaking for the data parameter — both receive the full flat row.
Run npx @payloadcms/codemod --transform migrate-import-export-hooks to migrate automatically. The codemod moves the function values into the correct hook positions and merges with any existing hooks object. Review argument names in the migrated functions — row references must be renamed to siblingData and doc references renamed to data.
Database adapter types now use the main package exports
The /types subpath export has been removed from all Drizzle-based database adapter packages. All types are available directly from the main package entry point.
Old import | New import |
|---|---|
| |
| |
| |
| |
| |
If you use a declare module augmentation to extend GeneratedDatabaseSchema (typically found in generated schema files), update the module path as well:
Run npx @payloadcms/codemod --transform migrate-db-types-subpath to migrate automatically. The codemod handles import declarations, re-export declarations, and declare module augmentations.
findMigrationDir removed from @payloadcms/drizzle
The deprecated findMigrationDir re-export has been removed from @payloadcms/drizzle. Import it from payload instead:
Minimum supported Node.js version is now 24.15.0 (#16540)
The minimum supported Node.js version is now 24.15.0 (previously 18.20.2). Upgrade your runtime — along with any CI, Docker images, and deployment targets — to the latest node LTS version or newer before upgrading to 4.0.
Minimum supported Next.js version is now 16.4.0 (#16537)
Older versions of Next.js are no longer supported. Update Next.js to 16.4.0 or newer.
Minimum supported TypeScript version is now 6.0.3 (#16692)
Payload's generated types are no longer guaranteed to work on TypeScript versions below 6.0.3 (previously 5.7.3). Upgrade the typescript dependency in your project to 6.0.3 or newer.
The Slate rich text editor has been removed (#16359)
Starting in Payload 4.0, @payloadcms/richtext-lexical is the only supported rich text editor. The @payloadcms/richtext-slate package has been deleted and will no longer be published.
The following are removed:
- The
@payloadcms/richtext-slatepackage - The Slate → Lexical migration utilities in
@payloadcms/richtext-lexical/migrate - The
payload-plugin-lexicalmigration utilities - All Slate references in the documentation
The rich text adapter pattern itself is unchanged — editor: lexicalEditor({}) continues to work exactly as before, and third-party adapters following the same interface remain supported.
If you are still using Slate, do not upgrade to 4.0 yet. Stay on the latest 3.x release and complete the Slate → Lexical migration first.
Legacy Lexical HTML converters have been removed (#16548)
The deprecated HTMLConverterFeature, lexicalHTML, and the per-node converters.html API have been removed from @payloadcms/richtext-lexical. Use convertLexicalToHTML and lexicalHTMLField from the non-deprecated converter instead.
Custom nodes that define converters.html should export their converters instead.
Lexical now uses its built-in HTML element check (#17104)
The deprecated isHTMLElement utility has been removed from @payloadcms/richtext-lexical. It was a thin wrapper around x instanceof HTMLElement that duplicated a utility already exported by lexical. Import it directly from lexical instead.
lexical's built-in isHTMLElement is the canonical implementation and narrows against the node's owning-document HTMLElement, so it is also safe across iframes and other realms.
Lexical now uses the upstream Markdown package
@payloadcms/richtext-lexical no longer ships a vendored copy of @lexical/markdown. The package is now a direct dependency, every @lexical/* package was upgraded from 0.41.0 to 0.52.0, and @payloadcms/richtext-lexical/lexical/markdown is a pure passthrough of @lexical/markdown.
Most projects use markdown through the editor or through convertMarkdownToLexical / convertLexicalToMarkdown and need no changes. You are only affected if you import from @payloadcms/richtext-lexical/lexical/markdown directly.
$convertFromMarkdownString no longer merges adjacent lines by default. This matches upstream lexical. If you call it directly and relied on the old behavior, pass shouldMergeAdjacentLines: true (5th argument):
Markdown transformer defaults now match upstream lexical (not Payload's features). The vendored fork shipped a curated TRANSFORMERS set (no CODE, no LINK). Both @payloadcms/richtext-lexical/lexical/markdown (now a pure passthrough) and the default argument to $convertFromMarkdownString exported from @payloadcms/richtext-lexical now use lexical's TRANSFORMERS, which bundles CODE, LINK, and HEADING transformers that conflict with Payload's features. You are affected if you imported a group (TRANSFORMERS, ELEMENT_TRANSFORMERS, ...) from the passthrough path, or called $convertFromMarkdownString(markdown) from the main package without passing a transformer list. Use the converters instead, which build the list from your editor config:
If you keep calling $convertFromMarkdownString directly, pass editorConfig.features.markdownTransformers as the second argument. Enable payload/no-conflicting-lexical-markdown-imports (included in @payloadcms/eslint-config) to catch imports and calls that rely on these mismatching defaults.
Whitespace-only markdown table cells now import as an empty paragraph instead of a text node of padding spaces. This comes from the lexical upgrade, not Payload's own code. No action is required unless you assert on those padding spaces in stored content.
If you import lexical APIs directly through @payloadcms/richtext-lexical/lexical/*, also review the lexical breaking changes between 0.41.0 and 0.52.0. None of them require action for typical Payload usage.
If your app imports Lexical types or adds custom features, check these changes from 0.50.0. They also apply to imports through @payloadcms/richtext-lexical/lexical and its subpaths:
- Lexical packages are ESM only. Use ESM imports; CommonJS
require()needs Node.js 20.19 or later. SELECTION_CHANGE_COMMANDnow runs before DOM reconciliation. Defer DOM lookups and floating UI positioning with$onUpdate(() => editor.read('latest', callback)), or use an update listener for work that should run after every commit.- Call
node.getLatest().exportJSON()when serializing a node reference retained across an update. Payload continues to write the full JSON format; compact export is not enabled. SerializedEditorState<T>,SerializedElementNode<T>, andSerializedRootNode<T>no longer accept a type argument. Remove<T>if you only need the base node type. For a document with your own node types, use Payload'sTypedEditorState<T>instead. Without a type argument, Lexical'schildrenare still typed asSerializedLexicalNode[].ReturnType<Node['exportJSON']>can now infer partial JSON, making fields such asversionandurloptional. If your code expects the full format, use the node's explicit serialized type, such asSerializedLinkNode. A normalnode.exportJSON()call still returns the full format.LexicalNode.getCommonAncestorwas removed. Use$getCommonAncestor(a, b)?.commonAncestor ?? null; pass the parents of non-element nodes if you need the old method's behavior.
Lexical table support is no longer experimental
EXPERIMENTAL_TableFeature has been renamed to TableFeature now that the table feature is no longer experimental.
Stored content is unaffected.
<<<<<<< HEAD
Lexical: LexicalEditorProps renamed to LexicalEditorArgs
The LexicalEditorProps type exported from @payloadcms/richtext-lexical has been renamed to LexicalEditorArgs. It describes the arguments of the lexicalEditor function, not component props. The shape is unchanged.
Lexical: the version of nodes is ignored
Lexical deprecated the version property of serialized nodes, and now ignores it when it loads a document. Payload no longer reads it either.
Rich text written by the Payload 3.0 betas is no longer upgraded when it's loaded. Payload used version: 1 to detect two older shapes:
- Block nodes whose fields were wrapped in an extra
dataobject (fields.data) - Relationship and upload nodes that stored the whole related document in
value, instead of its ID
If your database may still contain rich text from the 3.0 betas, run upgradeLexicalData on the latest 3.x release before you upgrade to 4.0. It re-saves every document, which converts these shapes:
version is optional in the JSON schema of Lexical nodes. The CLI and MCP no longer require it, and leave it out of the schema they show agents. They still accept it, so existing documents, which contain version, stay valid input.
Payload doesn't add or remove version when it saves rich text. Rich text created by Lexical, like in the admin panel, still has version: 1 on every node, because Lexical's JSON export writes it. Payload's own nodes used to write their own numbers instead (like 2 for blocks), and now write 1 as well. Rich text sent through the API, CLI or MCP is saved as sent, so it may not contain version. The generated types still include version, marked as deprecated, so don't rely on it being there.
Field typescriptSchema renamed to jsonSchema
=======
Field schema configuration now reflects JSON Schema
> > > > > > 7dd8d28c6a (cleans up headings that contained code blocks, updates titles on migration guides)
typescriptSchema accepted JSON Schema, not TypeScript, so the name lied. Renamed to jsonSchema. Same shape, same array of transforms. The underlying schema is now also consumed by the MCP plugin for runtime validation, not just type generation.
Rich text output schemas have been renamed
The Rich Text adapter's outputSchema property has been renamed to jsonSchema. The arguments object also gains typeStringDefinitions: Set<string> — a sink for raw TS source appended to payload-types.ts.
@payloadcms/richtext-lexical is updated. Only third-party adapters need attention.
Schema generation now returns definitions alongside JSON Schema
configToJSONSchema (from payload) used to return a JSONSchema4 directly. It now returns an object with the schema plus the TS-source sink. Destructure to pull the schema out.
If you run your own type generation, append [...typeStringDefinitions].join('\n\n') to the compiled output. Otherwise ignore it.
Field schema generation now uses object arguments
fieldsToJSONSchema used to take 6 positional arguments. It now takes one object. The new shape also requires typeStringDefinitions, and forceInlineBlocks is a top-level sibling instead of nested under opts.
Entity schema generation now requires type definitions
entityToJSONSchema now requires a typeStringDefinitions argument at position 5, between defaultIDType and collectionIDFieldTypes. The old opts: ConfigToJSONSchemaOptions is replaced by an optional positional forceInlineBlocks?: boolean at the end.
Lexical nodes now provide their own JSON Schemas
generatedTypes.modifyJSONSchema on lexical features (and the sanitized modifyJSONSchemas array) is removed. Features now contribute per-node JSON Schemas via createNode({ jsonSchema, node }) instead of mutating a whole-field schema after the fact. The editor composes per-node schemas into the field's union automatically.
If you wrote a custom feature that overrode modifyJSONSchema, move per-node generation into createNode({ jsonSchema: ... }):
Nodes without a jsonSchema function are omitted from the generated node union entirely — they're filtered out, with no fallback shape. Define jsonSchema for every custom node you want reflected in the types; otherwise that node won't appear in the field's generated type, and editor state containing it won't be assignable to it.
Lexical: duplicate node registration throws
sanitizeServerFeatures now rejects features that register a node type already present in the editor (matched on getType(), or on the replacement target for LexicalNodeReplacement). The old behaviour silently let the last write win.
The built-in lists features coordinate registration through shouldRegisterListBaseNodes, so the default editor is unaffected. Custom features that re-registered an existing node to swap in a subclass should use lexical's LexicalNodeReplacement shape:
Every block now auto-generates a top-level TypeScript interface
Before, a block's fields only became a top-level interface in payload-types.ts if you set interfaceName on the block. Without it, the fields were inlined wherever the block appeared in your types.
Now every block always emits a top-level interface. The name comes from the slug, converted to PascalCase ('content-block' → ContentBlock, 'richTextBlock' → RichTextBlock). interfaceName still works, but it's now an override for that default name rather than the switch that enables generation.
The same rule applies to lexical block nodes — they now emit as SerializedBlockNode<…> / SerializedInlineBlockNode<…> referencing the per-block interface, instead of inlining a { [k: string]: unknown } shape.
When two different blocks resolve to the same auto-derived name (same slug, different fields — e.g. a shared block overridden in one collection), the first keeps the clean name and any later block with a different shape gets a stable content-hash suffix (Hero_3F2A1B0C), so neither is silently mistyped. To choose the name yourself — or to resolve a collision with another type in your generated file (a collection, an array's interfaceName, etc.) — set an explicit interfaceName on the block. See Block interface name collisions.
Block references now share the blocks array
The blockReferences property on blocks fields has been removed. The blocks property now accepts both inline block configs and string slugs for blocks defined in the top-level Payload Config blocks array.
Run npx @payloadcms/codemod --transform migrate-block-references-to-blocks to migrate automatically.
Inline blocks continue to work as before:
Generated Lexical types are now strict
richText fields backed by lexicalEditor used to emit a permissive { [k: string]: unknown } per field. They now emit a discriminated union of strict per-node interfaces (SerializedTextNode, SerializedParagraphNode, SerializedHeadingNode, SerializedBlockNode, etc.) under a hash-named alias like LexicalNodes_AB12CD34.
Block nodes come through as SerializedBlockNode<TFields> / SerializedInlineBlockNode<TFields> referencing the per-block interface (see the block.interfaceName change above). The auto-added id is now required (id: string), since a stored lexical block always has one.
Code that imported field types from payload-types.ts and cast through unknown may now type-check tighter and surface real bugs.
The hand-written TypedEditorState / DefaultTypedEditorState helpers (used to type rich text in converters, custom renderers, and so on) got stricter to match. TypedEditorState dropped the { [k: string]: unknown } at the editor-state root, and a node's children are now the real node union at any depth — they used to fall back to the loose SerializedLexicalNode. Runtime data is unchanged, but narrow by node.type where you read nested nodes loosely.
Relationship and upload node value - and the exported RelationshipData / UploadData types - now use each collection's own ID type instead of number | string. This is narrower, but it only removes an ID type that was never valid for that collection (e.g. number for a string-ID collection).
Element nodes are now generic over their children, and you pass the node union into them yourself (previously TypedEditorState did this for you via an internal RecursiveNodes helper):
SerializedTextNode is a leaf, so it stays bare. For the built-in nodes plus a few of your own, WithDefaultNodes threads the union for you - pass your custom node(s) as the generic (type MyNodes = WithDefaultNodes<SerializedBlockNode<MyBlockData>>), and they're usable anywhere a node can appear, not just at the top level. DefaultTypedEditorState with only built-in nodes needs no change.
Generated JSON Schema now uses modern definitions
Payload builds a JSON Schema from your config to generate payload-types.ts. That schema now stores its reusable types under the modern $defs key instead of the older definitions key, and every internal reference points at #/$defs/... instead of #/definitions/.... This follows the current JSON Schema spec.
Your generated payload-types.ts does not change. You are only affected if you read or write that schema yourself — most commonly through typescript.schema on your config, a custom rich text adapter, or a custom Lexical feature.
Update any code that adds to the schema:
And update any $ref pointers you wrote by hand to point at $defs:
Do not keep your additions on definitions while new ones land on $defs.
Jobs: access is enforced by default
The generated payload-jobs collection now denies generic create, read, update, and delete access by default. This affects REST and GraphQL CRUD, plus Local API collection operations because overrideAccess now defaults to false.
Dedicated operations such as payload.jobs.queue(), payload.jobs.run(), payload.jobs.runByID(), payload.jobs.cancel(), and payload.jobs.cancelByID() also default overrideAccess to false. They now run the corresponding jobs.access.queue, jobs.access.run, or jobs.access.cancel function when the option is omitted. Add overrideAccess: true to trusted server-side calls that must preserve the Payload 3 behavior of bypassing Jobs access control.
If your application intentionally exposes the internal payload-jobs collection, configure only the access it needs through jobsCollectionOverrides. For example, the following enables read-only Admin Panel access for trusted administrators:
Job documents can contain inputs, outputs, logs, and execution state, so avoid enabling write access unless your application specifically requires it.
Job queries and hooks now have fixed behavior (#16414)
The deprecated jobs.depth and jobs.runHooks config options have been removed. Job operations now always use direct database calls with depth 0.
If you relied on either option, remove it from your jobs config. If you depended on collection hooks running for jobs, migrate that logic out of those hooks.
The jobsCollectionOverrides warning that detected extra hooks and prompted users to set runHooks has also been removed.
The legacy base job type has been removed
The deprecated BaseJob type is no longer exported. Use Job everywhere you previously used BaseJob.
Job now handles its own fallback. When the generated payload-jobs collection type exists, Job uses it. Otherwise, it uses the default job document from UntypedPayloadTypes. Consumers no longer need to choose between a generated job type and a separate fallback type.
The false fallback used by job and workflow generics has also been removed. Omit the generic when you want the default object input type.
Generated workflow slugs and explicit input objects continue to work as before. For example, Job<'my-workflow'> uses the generated workflow input, while Job<{ message: string }> uses the provided input type.
Jobs: stats and metadata are enabled by default
When jobs are enabled, Payload now always adds the payload-jobs-stats global and the meta field on the payload-jobs collection. Previously, these were only added when a task or workflow used a schedule.
Existing projects that use sqlite/postgres and jobs may need a database migration.
Jobs: concurrency controls and parent task logging are always enabled
Concurrency controls add the indexed concurrencyKey field to the payload-jobs collection. Parent task logging adds parent information to nested task logs. Task log input is also now required and is stored as an empty object when a task has no input.
The jobs.enableConcurrencyControl and jobs.addParentToTaskLog options have been removed. Existing SQL projects that use jobs should generate a database migration for these schema changes.
Jobs: task and workflow types have been renamed
The TaskType and WorkflowTypes exports have been renamed to TaskSlug and WorkflowSlug.
The deprecated RunningJob and RunningJobSimple types have also been removed. Use Job instead.
JSON workflow steps without a task must now provide an inlineTask handler. This removes a type escape that previously allowed an incomplete step even though it could not run.
Custom database adapters must support job claiming
Custom database adapters must now implement updateJobs so Payload can use a unique processingToken when claiming jobs and prevent multiple workers from running the same job. This adds an internal, nullable processingToken field to the payload-jobs collection. Existing SQL databases need a migration that adds this column. MongoDB does not require a migration.
The default updateJobs implementation has been removed because it could not safely claim jobs across multiple workers. If you maintain a custom database adapter, you must now provide updateJobs. It must only claim jobs that still match the provided where condition and, when a processingToken is provided, return only the jobs claimed with that token. The official Payload database adapters already implement this behavior.
Jobs: crashed workers are recovered with processing leases
The permanent processing flag on payload-jobs has been replaced by a renewable processingUntil lease. If a worker exits, a later jobs runner call can claim the job after its lease expires. Worker crashes do not consume the job's handler retry configuration.
Worker updates use a 30-second safety buffer by default, so an update only starts when enough lease time remains for it to finish before another claim. Configure the lease with jobs.processingLease.duration and jobs.processingLease.safetyBuffer.
Existing SQL databases need a migration that replaces processing with the nullable, indexed processingUntil column. MongoDB does not require a migration.
Job task results no longer include state (#16416)
Job task handlers and inlineTask callbacks can no longer use the state field to signal success or failure. Both shapes were deprecated in v3 and are now removed from tasks and inline tasks.
return { state: 'failed', errorMessage }— throw an error instead.return { state: 'succeeded', output }— return{ output }directly.
Cron expressions now require explicit ranges (#16546)
The sloppyRanges: true setting that kept Croner v9 stepping syntax working after the v10 upgrade has been removed. Croner now enforces stricter cron parsing rules, so stepping expressions must use either * or an explicit range before the /.
This affects job autorun crons (jobs.autoRun[].cron), scheduled task and workflow runs (schedule.cron), and the payload bin script --cron flag.
In strict mode (now the default), only these stepping forms are valid:
*/N— wildcard with stepstart-end/N— explicit range with step
The following forms now throw a TypeError at registration:
/N— missing prefixM/N— numeric prefix (single number before/)
Migrate any affected cron expressions:
Relationship and upload limits now use row-based options (#16547)
The deprecated min and max properties have been removed from relationship and upload fields with hasMany: true. Use minRows and maxRows instead.
The same change applies to upload fields with hasMany: true.
Nested localized fields no longer need a compatibility flag (#16693)
config.compatibility.allowLocalizedWithinLocalized and the PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY environment variable have been removed. Sanitize no longer strips localized: true from fields nested under a localized parent — fieldShouldBeLocalized decides this at runtime instead.
Who is affected:
- Users of
compatibility: { allowLocalizedWithinLocalized: true }. - Anyone with
localized: truenested under a localized parent. The end behavior is unchanged (Payload's own code already usesfieldShouldBeLocalized), butfield.localizedis no longer deleted. Custom plugin or hook code that readsfield.localizeddirectly will now seetruewhere it previously sawundefined.
How to migrate:
- Remove the
compatibilityblock from your config. If your MongoDB data still has the redundant nested-localized shape, flatten the config and run a data migration. - Replace any direct
field.localizedreads in custom code withfieldShouldBeLocalized({ field, parentIsLocalized })frompayload/shared.
Publishing now defaults to the active locale
The Admin UI Publish button now always defaults to publishing only the active locale when a collection or global has localized fields and you have publish permission. "Publish all locales" is available as a secondary option in the Publish button's dropdown. This previously required opting in via localization.defaultLocalePublishOption: 'active'; the config option has been removed since active-locale-only is now the permanent default.
What changes:
localization.defaultLocalePublishOptionno longer exists as a config property or type — remove it from your config.- If you had it set to
'active', there is no change in Admin UI behavior. - If you had it set to
'all', or left it unset, the Publish button now publishes only the active locale by default. Use the "Publish all locales" option in the button's dropdown to publish every locale at once.
Run npx @payloadcms/codemod --transform remove-default-locale-publish-option to remove the property automatically. The codemod surfaces a note for any config that had it set to 'all', since that combination previously changed the default and now has no config equivalent.
Storage adapters have moved to top-level configuration (#16596)
Storage adapter packages (@payloadcms/storage-s3, -azure, -gcs, -r2, -vercel-blob) must now be placed under the top-level storage key in your Payload config instead of inside plugins.
Declaring adapters inside plugins no longer works. The storage key ensures upload hooks, static file handlers, and upload instructions are wired up before any plugin runs.
Run npx @payloadcms/codemod --transform migrate-storage-adapters-to-config to migrate automatically.
Direct uploads now use Payload's shared upload-instructions endpoint
Official storage packages now use POST /api/upload-instructions instead of provider-specific endpoints.
If you use an official Payload storage adapter
If you only enable clientUploads in your storage adapter config, no migration is needed. The Admin Panel uses the new endpoint automatically.
Payload now checks upload.limits.fileSize before returning upload instructions. This is new for Azure, GCS, R2, and Vercel Blob; S3 already checked it. Increase the limit if your direct uploads previously exceeded it.
The old S3, Azure, and GCS signing endpoints (e.g. /api/storage-s3-generate-signed-url) were removed. If you called one directly, call /api/upload-instructions instead:
For S3 and GCS, send the file using the returned HTTP request, then pass instructions.file unchanged when creating the document:
Remove manual registrations of S3ClientUploadHandler, AzureClientUploadHandler, and GcsClientUploadHandler; Payload now registers them. The S3 and GCS /client exports were also removed.
If you typed clientUploads.access, replace ClientUploadsAccess with UploadInstructionsAccess:
If you maintain a custom storage adapter
File versioning adds a required GeneratedAdapter.copyFile method. Copy from to to (complete storage keys) without changing the source, reject an existing destination, and return only once the destination is readable. moveFile stays optional. See Custom Storage Adapters for the full contract.
Replace clientUploadContext with uploadReference in generated file metadata and static handler parameters. handleUpload no longer receives it because Payload skips uploading files that are already stored.
Set uploadInstructions.enabled. When false, Payload adds adminHandler to the import map without rendering it. When true, Payload also renders it and exposes the instructions and supporting endpoint.
Move access checks into the generator or supporting endpoint that authorizes the upload:
useUploadHandlers().setUploadHandler was removed. Name custom client handlers and return the same name in a dispatch instruction:
Set useInAdmin to true when the Admin panel should use the instructions instead of attaching the whole file to the document request. This can also benefit chunked uploads that still pass through Payload.
R2's multipart client route is one example: it passes chunks through Payload, but the Admin still uses it to avoid sending the whole file in one request.
The data value is passed from the server instruction to the client handler. The names must match exactly. If the handler returns an object, it becomes file.uploadReference in the document request.
Finally, remove initClientUploads and declare its Admin handler and supporting endpoint on uploadInstructions:
Both are optional. Custom handler props are now passed as props, and the supporting route is available as endpointPath instead of serverHandlerPath. The old Admin component enabled prop was removed.
The Uploadthing storage adapter has been removed
The Uploadthing storage adapter, deprecated in a previous release, has been deleted and will no longer be published. Migrate to another storage adapter such as @payloadcms/storage-s3 before upgrading to 4.0.
The migrate-storage-adapters-to-config codemod no longer recognizes uploadthingStorage — migrate uploadthing configs to another adapter manually.
Azure client uploads now use the Azure Blob SDK — update your CORS rules
@payloadcms/storage-azure client uploads (clientUploads) now upload through the Azure Blob SDK, which splits files into blocks (Put Block + Put Block List). This lifts the ~5GB single-request limit and raises the effective ceiling to Azure's block-blob maximum (~190TiB).
This is an infrastructure change, not a code change — it cannot be auto-migrated. The SDK sends additional x-ms-* headers and issues CORS preflight (OPTIONS) requests, which is broader than the previous single-PUT flow required. If your storage account's CORS rules are scoped narrowly to the old flow, browser uploads will start failing with RestError: Failed to fetch.
Update the CORS rules on your storage account (Storage account → Resource sharing (CORS) → Blob service) so that they allow:
Field | Value |
|---|---|
Allowed origins | Your site origin (e.g. |
Allowed methods | |
Allowed headers | |
Exposed headers | |
Max age | |
If you already set Allowed headers to *, no change is required.
If you enabled large-file uploads in v3 via the clientUploads.chunkLargeFiles flag, remove it — chunked uploads are now the default in v4 and the option no longer exists:
Run npx @payloadcms/codemod --transform migrate-azure-chunk-large-files to remove the flag automatically (it collapses clientUploads: { chunkLargeFiles: true } to clientUploads: true and keeps any other clientUploads options).
Sharp is now an optional file transformer
Image resizing is no longer built into payload itself. It now ships as an official file transformer, @payloadcms/transformer-sharp, that you register under upload.transformers.
This affects four things:
- The top-level
sharpconfig property has been removed. Passing a Sharp instance now happens throughsharpTransformer({ sharp })instead. - Per-collection Sharp options —
constructorOptions,formatOptions,resizeOptions,trimOptions, andwithMetadata— have been removed fromCollectionConfig['upload']. They're now authored throughsharpTransformer({ collections }).
imageSizeshas also been removed fromCollectionConfig['upload']. Image sizes are now declared asvariantsonsharpTransformer({ collections: { <slug>: { variants } } }). The entries themselves (name,width,height,generateImageName, and so on) are unchanged. A collection that still setsupload.imageSizesfails the build.
sharpis no longer a dependency ofpayloadat all (previously adevDependency, not a runtimedependency). Install@payloadcms/transformer-sharpexplicitly.- The Sharp-specific types
SharpDependency,ImageUploadFormatOptions,ImageUploadTrimOptions, andSharpImageSizeOptionsare no longer exported frompayload. ImportSharpDependencyfrom@payloadcms/transformer-sharp. For the option types, useSharpCollectionConfig['formatOptions'],SharpCollectionConfig['trimOptions'], andNonNullable<SharpCollectionConfig['variants']>[number]from the same package.
Run npx @payloadcms/codemod --transform migrate-sharp-to-transformer to migrate automatically. The codemod moves a top-level sharp dependency and inline collection Sharp options — including crop and focalPoint — into sharpTransformer({ collections }) (writing imageSizes as variants), appends to an existing upload.transformers array if you already have one, and leaves anything it can't safely rewrite in place with a note — for example, a collections array built by a function call, or a collection defined outside the buildConfig call. Review those manually. For a config that already registers sharpTransformer, it only renames imageSizes to variants inside the existing sharpTransformer call and flags a leftover top-level sharp property; it doesn't move any remaining collection options.
Your variants, crop, and focalPoint are readable at collection.upload.variants/.crop/.focalPoint on the sanitized config (for example req.payload.collections.media.config.upload.variants). Code that read the sanitized upload.imageSizes in 3.x must read upload.variants instead. sharpTransformer writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel and the rest of core see the same configuration. For a collection configured on sharpTransformer, a crop or focalPoint set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither imageSizes nor variants can be set on the collection at all.
If you inject a custom Sharp build or version, pass it the same way:
sharpTransformer can also resize images at request time (/api/media/file/photo.png?width=400). This is new in Payload 4 and disabled by default, so a migrated config keeps serving only the original file and your pre-generated variants. The codemod doesn't enable it. To opt in, pass dynamic: true or dynamic: { collections: ['media'] } — see Dynamic (request-time) resizing, and Securing dynamic resizing before enabling it on a publicly readable collection.
Upload image data now uses variants
The group field that holds each generated image size on an upload document is now variants instead of sizes. Every place that reads or queries it changes:
GraphQL queries select variants { thumbnail { url } } instead of sizes { thumbnail { url } }, and regenerated types expose variants?: { ... } (and variants?: T | { ... } on the *Select types) in place of sizes. Update any frontend code that reads media.sizes, including values you pass to helpers such as getBestFitFromSizes({ sizes: doc.variants }).
Existing documents must be migrated. Create the predefined migration for your database adapter and run it:
Postgres and SQLite (including Vercel Postgres and D1): the columns are renamed in place from sizes_<name>_<property> to variants_<name>_<property>, on each upload collection's table and its versions table, along with their indexes. Use the predefined migration above instead of a plain payload migrate:create for this change. A generated migration would drop the sizes_* columns and add new variants_* columns, losing the stored image size data. The predefined migration also writes the schema snapshot, so the next generated migration starts from the renamed columns.
On MongoDB the migration renames sizes (and version.sizes on versions) with $rename and re-creates the indexes on those fields under the new path. Run the migration before the upgraded app writes to upload documents.
The file endpoint changes once any transformer is registered
Registering any transformer under upload.transformers — including sharpTransformer() with dynamic resizing off — routes every file request through the transformer-aware handler:
- A file must belong to an upload document to be served. A file that exists in storage without a matching document now returns
404. - For files whose MIME type a transformer declares (every image, with
sharpTransformer()),access.readcan run twice per request: once withreq.fileTransformset totrueto decide a possible transform, and once without it for an ordinary read. Keepaccess.readcheap, and usereq.fileTransformto restrict transformed reads — see Access control. - Responses produced by a transformer always carry Payload's CORS headers, which
modifyResponseHeaderscan't override.
To link to a file from your own code, including with transformer query parameters, use the new generatePayloadFileURL helper (exported from payload and payload/shared). It always builds the access-controlled Payload file endpoint, so links never bypass access.read by pointing straight at storage.
Storage prefixes are now always included
The alwaysInsertFields option (on cloudStoragePlugin and every storage-* adapter: -s3, -azure, -gcs, -r2, -vercel-blob) has been removed. The behavior it used to opt into — always inserting the hidden prefix field into the collection schema — is now unconditional, so the schema stays consistent whether or not a collection configures a prefix and whether or not the plugin is enabled.
Who is affected:
- Anyone with an upload collection that uses a storage adapter or
cloudStoragePlugin. Every such collection now gets a hiddenprefixfield, including collections that never configured aprefixand collections where the plugin is disabled (enabled: false). - Anyone who set
alwaysInsertFieldsexplicitly — the option no longer exists on any storage adapter or plugin option type. - Postgres, SQLite, and D1 users: the new field is a new database column. Running
payload migrate:createwill generate a migration that adds aprefixcolumn to every affected collection's table.
How to migrate:
- Remove
alwaysInsertFieldsfrom your storage adapter orcloudStoragePluginconfig — it has no effect and no longer exists on the option types. - For Postgres, SQLite, or D1 projects, run
payload migrate:createto generate the migration that adds theprefixcolumn, then runpayload migrateto apply it.
<<<<<<< HEAD
Upload collections retain originals and file history
Upload collections now store original metadata and a prefix and _objectKey for each stored file (the main file, original, and each variant), so saved versions can find their files. New uploads keep their source under an -original filename. Update any code or infrastructure that relies on the old filename, URL, or object key.
Postgres and SQLite: after updating, run payload migrate:create, check that it adds these columns to each upload collection table and its versions table, then run payload migrate. Existing rows keep null values until their next write.
FileSizeImproved removed — use FileSize (#16593)
=======
File size types have been consolidated (#16593)
> > > > > > 7dd8d28c6a (cleans up headings that contained code blocks, updates titles on migration guides)
FileSizeImproved has been merged into FileSize. The url, width, height, filesize, mimeType, and filename properties now accept null directly on FileSize, matching what the database stores for sizes that were not generated.
Image size admin controls have been consolidated (#16593)
The disableListColumn, disableListFilter, and disableGroupBy properties under ImageSize.admin have been replaced with a single disabled object, consistent with the shape used by all other fields.
Run npx @payloadcms/codemod --transform migrate-disabled-fields to migrate automatically.
MongoDB queries no longer use facet aggregation (#16612)
The useFacet option in mongooseAdapter config has been removed. Payload no longer uses $facet aggregation, so the option has no effect.
Search plugin API paths are now derived automatically (#16597)
The deprecated apiBasePath option on the search plugin has been removed. The plugin now derives the correct path from the root config automatically.
Remove any apiBasePath key from your search plugin config.
API keys created before v3.46.0 must be regenerated (#16628)
The sha1 HMAC fallback for API key authentication has been removed. API keys are now matched exclusively against the sha256 index. Any API key that was created before v3.46.0 and never re-saved will no longer authenticate.
How to migrate: Open the user document for each affected API key holder and save it — this regenerates the key under sha256. Alternatively, generate a new API key from the admin panel.
API key fields now use a factory
The baseAPIKeyFields export from payload has been removed. Use createAPIKeyFields() instead. It returns the same default auth API key storage fields, and also accepts overrides for projects or plugins that need to customize the generated field configs.
If you previously cloned or modified baseAPIKeyFields, pass field overrides to createAPIKeyFields() instead:
MCP plugin configuration and authentication have changed
@payloadcms/plugin-mcp has a new public config API, built-in tool inputs, and API-key authentication flow. Update your plugin config and recreate MCP credentials as user API keys.
Collections and globals are now opt-out. Drop the enabled flag. Every collection and global is exposed by default through the generic built-in tools. For collections, this includes tools like getCollectionSchema, findDocuments, countDocuments, createDocuments, updateDocument, deleteDocuments, duplicateDocument, and findDistinct, plus version tools when versions are enabled. For globals, this includes getGlobalSchema, findGlobal, and updateGlobal, plus version tools when versions are enabled. Turn individual operations off via simple config keys like tools: { create: false }.
After upgrading, collections you never listed before are reachable over MCP. Review what's exposed and disable anything that shouldn't be.
Custom tools, prompts and resources moved from arrays under mcp to top-level records. Use the new defineTool / defineCollectionTool / defineGlobalTool builders for tools, and definePrompt for prompts. Resources are plain objects in the top-level resources record. For tools, parameters (a Zod raw shape) became input (a Zod/Valibot schema or raw JSON Schema), and handlers take one object argument instead of positional ones.
Prompts and resources move the same way, into top-level prompts and resources records.
Tool handlers now receive authorizedMCP in that object argument. It contains
the authorized MCP items and the overrideAccess value, but not the user.req.user is now the single source of truth for the caller's identity. Unlike
the v3 MCP API-key resolver, the default v4 resolver assigns the authenticated
user to req.user before custom handlers run.
If a custom tool calls Payload's local API, pass req andauthorizedMCP.overrideAccess. The local API reads the user from req.user:
Collection and global tool calls now use slug instead of collectionSlug or globalSlug. Update saved tool inputs and any code that calls these tools directly.
For custom collection and global tools, do not add slug yourself. Payload adds it to the MCP schema automatically, and the handler receives the resolved slug as a top-level argument.
Built-in collection tools are now generic. Per-collection names like createPosts / updatePosts were replaced by createDocuments / updateDocument. Pass the target collection as slug. For creates, pass a documents array whose items contain data and an optional file; for updates, pass data with an id or where at the top level. Options such as depth, draft, and locale also remain at the top level. _status, id, createdAt, and updatedAt are no longer accepted inside data; set publish state with a custom tool if you need to.
createDocuments and updateDocument now return affected document IDs instead of complete documents by default. Add returning: true to preserve the previous response. If the call uses select, it must also set returning: true.
MCP URL uploads require an explicit allow-list. Previously, MCP could download a URL when the upload collection's pasteURL was unset. Configure upload.pasteURL.allowList for collections that accept MCP URL uploads. The initial URL and any redirect targets must match; skipSafeFetch does not bypass this requirement. Base64 and upload-reference inputs are unaffected.
Auth tools are per-collection and opt-in. The old experimental.tools.auth block is gone. Opt in per auth collection via its tools map, and pass the target auth collection as slug. Like every MCP item, auth tools now use default MCP access unless you provide an access callback, so unauthenticated auth tools such as login need access: () => true.
mcp.handlerOptions is gone. verboseLogs moved to mcp.verboseLogs. onEvent, maxDuration, disableSse, redisUrl and basePath were removed. The experimental.tools block (auth, plus the collection / job / config codegen tools) was removed entirely. If you opted into SSE/server-push with disableSse: false, remove that config; v4 only supports POST streamable HTTP.
overrideAuth was replaced by overrideGetAuthorizedMCP. The hook receives
one argument object and returns an AuthorizedMCP instead of the oldMCPAccessSettings type.
In v3, the resolved user was stored on MCPAccessSettings.user. In v4, a custom
resolver must assign the user directly to req.user and return only items andoverrideAccess. Always assign req.user, including null for an anonymous
caller. The custom hook replaces the default authentication and item filtering,
so it must also return only the MCP items that caller may use.
The default v4 resolver assigns req.user automatically when it authenticates
a normal Payload user API key. Only custom overrideGetAuthorizedMCP
implementations need to assign it themselves.
MCP now uses user API keys. The payload-mcp-api-keys collection was removed. API keys sent to MCP now use Payload's normal Authorization: <authCollectionSlug> API-Key <key> header and Payload's normal auth.useAPIKey fields. Enable auth.useAPIKey on the auth collection whose users should call MCP. Existing payload-mcp-api-keys documents no longer authenticate, and overrideApiKeyCollection was removed.
The userCollection option was also removed. MCP now accepts any Payload user authenticated through API-key auth, using the collection slug from the Authorization header.
Because the payload-mcp-api-keys collection config was removed, run your normal database migration workflow after upgrading so the old collection/table is removed where your database adapter requires schema migrations. Back up any existing MCP API key documents first if you need to reference them while recreating credentials as user API keys.
MCP tool access is configured in code. The old per-key permissions UI was removed with the MCP API-key collection. Tools, prompts, and resources can now define an access callback; built-in collection/global tools accept access in their override object.
By default, MCP items require an authenticated user. Built-in collection and global tools also include Payload permission checks in their default access callbacks. If you provide an access callback for a built-in tool, it replaces the default callback.
Dependencies. The plugin now builds on @modelcontextprotocol/server instead of @modelcontextprotocol/sdk + mcp-handler. It no longer depends on Zod directly; its built-in tools use the Zod Mini export from payload. If your custom tools use regular Zod, install Zod v4 in your project and import it directly.
HTTP requests have a size limit. POST /api/mcp now answers request bodies over 4 MiB with 413. Base64 file uploads count toward this limit. Raise it with mcp.maxRequestBodySize.
Admin UI styles now use CSS and semantic tokens
The Admin UI styles were migrated from SCSS to plain CSS. The @payloadcms/ui/scss package export and its SCSS source files have been removed, and the legacy --theme-* CSS variables were retired in favor of semantic --color-* tokens and the raw --ramp-* color palette.
Stylesheet imports. SCSS partials and mixins are gone. All design tokens are now global CSS variables on :root, so custom stylesheets no longer need to import anything to consume them — remove any @import '~@payloadcms/ui/scss'; line. If you referenced the package export directly, use the CSS export instead:
Token renames. The --theme-* variables were replaced by semantic --color-* tokens. Update any custom CSS that referenced them:
The --theme-elevation-* scale was removed. Use the semantic --color-* tokens (e.g. --color-bg, --color-text, --color-border, --color-icon) or the raw --ramp-* palette instead. See Customizing CSS for the full list of available tokens.
Any custom component .scss files you authored should be renamed to .css and updated to plain CSS syntax — Payload no longer ships a SCSS toolchain.
Ecommerce Stripe confirmations use an atomic transaction claim
Stripe confirmation now atomically claims the existing ecommerce transaction before creating an order,
marking the cart as purchased, and decrementing inventory. Retries return the canonical order only when
the succeeded transaction and order are linked in both directions. Ambiguous state fails closed.
PostgreSQL users must generate and run a database migration that adds processing to the ecommerce
transaction status enum. No order field, unique index, or data backfill is required. The publicconfirm-order response continues to include transactionID, including on retries. This is a breaking
security change for custom payment adapters: after provider validation, they must call the newfinalizeOrder argument instead of creating orders themselves. The bundled Stripe adapter is updated in
the same release. See the payment confirmation guidance for
transactionless database failure behavior and reconciliation guidance.
Stripe REST proxy requires an explicit method allowlist
@payloadcms/plugin-stripe no longer accepts rest: true. To enable the REST proxy in v4, use
an object with a non-empty allowedMethods array of exact Stripe SDK method names, such assubscriptions.list:
Wildcards are not supported. The optional rest.access callback can further restrict the
endpoint. Without it, the proxy requires an authenticated user allowed to access the Payload
admin panel. See the Stripe REST proxy documentation for
configuration and security guidance.
If you previously used rest: false, remove the property; omitting rest disables the proxy.
API keys: v4-only changes on top of the shared 3.x/4 security fix
apiKeyLast4is a new persisted field on everyauth.useAPIKeycollection. Generate and run a schema migration. Legacy rows backfill on the next successful API-key authentication.enableAPIKeyis removed. Revoke by settingapiKey: null.hasAPIKey(3.x virtual field) is removed. UseapiKeyLast4anddocPermissions.fields.apiKey.- Custom
access.readon the generatedapiKeyfield is inert — reads are hardcoded to deny.
Uploads now reject oversized files by default
The Payload-wide upload.abortOnLimit option now defaults to true. A file that is bigger than the configured fileSize limit now returns an HTTP 413 error by default. Previously, the file was truncated to the limit without any error, and the upload continued.
If your application depends on the old truncate-on-limit behavior, set abortOnLimit: false explicitly:
Was this page helpful?