Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

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:

1
npx @payloadcms/codemod

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

createLocalReq

createPayloadRequest

CreateLocalReqOptions (internal type)

Omit<CreatePayloadRequestArgs, 'payload'>

createPayloadRequest

createPayloadRequestFromWebRequest

initReq

initAdminContext

InitReqArgs

InitAdminContextArgs

InitReqResult

AdminContext

InitReqCache

AdminContextCache

InitReqPartialResult

PartialAdminContext

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:

1
-import { createLocalReq } from 'payload'
2
+import { createPayloadRequest } from 'payload'
3
4
-const req = await createLocalReq({}, payload)
5
+const req = await createPayloadRequest({ payload })

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:

1
-import { createPayloadRequest } from 'payload'
2
+import { createPayloadRequestFromWebRequest } from 'payload'
3
4
-const req = await createPayloadRequest({ config, request })
5
+const req = await createPayloadRequestFromWebRequest({ config, request })

Codemod

To migrate automatically, there's a codemod for this change available by running:

1
npx @payloadcms/codemod --transform migrate-payload-request-creation

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.

1
// Decoded payload: unchanged paths
2
{
3
id: 'user-id',
4
collection: 'users',
5
sid: 'session-id',
6
email: 'user@example.com',
7
role: 'super-admin', // from saveToJWT
8
iat: 1750000000,
9
exp: 1750007200,
10
}
11
12
// Signed protected header
13
{
14
alg: 'HS256',
15
typ: 'JWT',
16
authVersion: 1,
17
}

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 401 responses 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:

1
import { decodeProtectedHeader } from 'jose'
2
import { JWT_AUTH_VERSION } from 'payload'
3
4
if (decodeProtectedHeader(token).authVersion !== JWT_AUTH_VERSION) {
5
throw new Error('Unsupported Payload token')
6
}

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.

1
const posts = await payload.find({
2
collection: 'posts',
3
+ overrideAccess: true, // preserves the Payload 3 default
4
})
  • overrideAccess: false (the new default) — respect Access Control. Use this whenever the operation acts on behalf of a user, and pass user alongside 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:

1
npx @payloadcms/codemod

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 whether args already supplies the property.
  • Aliased or cast receivers — const db = payload; db.find({ ... }) and (payload as any).find({ ... }). It matches payload, .payload, and their .jobs properties 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:

1
import type { CollectionBeforeChangeHook } from 'payload'
2
3
const syncCustomer: CollectionBeforeChangeHook = async ({
4
data,
5
operation,
6
}) => {
7
if (operation === 'validate') {
8
return data
9
}
10
11
await syncCustomerWithStripe(data)
12
13
return data
14
}

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:

1
// payload.config.ts
2
export default buildConfig({
3
bin: [
4
{
5
key: 'seed',
6
scriptPath: path.resolve(dirname, 'seed.ts'),
7
},
8
],
9
})

The imported module had to expose a specially named script function:

1
// seed.ts
2
import type { SanitizedConfig } from 'payload'
3
4
import payload from 'payload'
5
6
export const script = async (config: SanitizedConfig) => {
7
await payload.init({ config })
8
// Seed the database...
9
process.exit(0)
10
}

In Payload 4.0, define a real CLI command instead:

1
// seed.ts
2
import { strictObject, z } from 'payload'
3
import { defineCLICommand } from 'payload/cli'
4
5
export const seedCommand = defineCLICommand({
6
description: 'Seed the database.',
7
input: strictObject({
8
clear: z
9
._default(z.boolean(), false)
10
.check(z.describe('Delete existing documents first.')),
11
}),
12
handler: async ({ args, getPayload }) => {
13
const payload = await getPayload()
14
15
if (args.clear) {
16
// Delete existing documents...
17
}
18
19
// Seed the database...
20
payload.logger.info('Successfully seeded!')
21
},
22
})

Register the command export in your Payload config:

1
// payload.config.ts
2
export default buildConfig({
3
cli: {
4
commands: {
5
seed: './seed.js#seedCommand',
6
},
7
},
8
})

When migrating custom scripts:

  • Replace bin entries with entries in the cli.commands map. The map key becomes the command name.
  • Replace the exported script(config) function with a schema-backed command created by defineCLICommand.
  • Register commands by import path to keep them out of the Payload config's module graph. Paths use the existing PayloadComponent syntax, resolve relative to payload.config.ts, and support default or named exports. Direct CLICommand values 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 cli only 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:

1
await payload.db.migrate()

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:

1
import payload from 'payload'
2
3
const { code } = await payload.bin({
4
args: ['migrate:create', '--file', migrationPath],
5
cwd: projectDirectory,
6
})

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:

1
const posts = await payload.find({
2
collection: 'posts',
3
overrideAccess: true,
4
depth: 2,
5
})

To preserve the Payload 3.x behavior application-wide, set defaultDepth: 2:

1
export default buildConfig({
2
+ defaultDepth: 2,
3
})

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 versions property will automatically get a _<slug>_versions table 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: true you may have added previously is now redundant and can be removed.

To opt out for an existing collection or global:

1
export const Posts: CollectionConfig = {
2
slug: 'posts',
3
fields: [],
4
+ versions: false,
5
}

Two codemods are available to automate this migration:

1
# 1. Preserve existing behaviour: add versions: false everywhere versions is not set
2
npx @payloadcms/codemod --transform migrate-versions-default
3
4
# 2. Remove now-redundant versions: true (bare boolean) from your configs
5
npx @payloadcms/codemod --transform remove-versions-true

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:

1
export const Posts: CollectionConfig = {
2
slug: 'posts',
3
access: {
4
read: () => true,
5
readVersions: ({ req: { user } }) => Boolean(user),
6
},
7
fields: [],
8
}

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 createdBy and updatedBy when your config has at least one auth collection. On SQL adapters this adds relationship rows (stored in each entity's _rels table), so existing projects need a database migration. In development the schema is pushed automatically; in production, generate a migration with payload migrate:create and run payload migrate. Existing documents are backfilled with null.
  • createdBy is only derived on a document's first write, so editing a document whose createdBy is null (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-ecommerce collections.
  • 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, set authorship: false through that plugin's collection-override option.

To opt out for an existing collection or global:

1
export const Posts: CollectionConfig = {
2
slug: 'posts',
3
fields: [],
4
+ authorship: false,
5
}

A codemod is available to preserve the previous behaviour:

1
npx @payloadcms/codemod --transform migrate-authorship-default

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.

1
export const Header: GlobalConfig = {
2
slug: 'header',
3
admin: {
4
components: {
5
- elements: {
6
- SaveButton: '/path/to/CustomSaveButton',
7
- Description: '/path/to/CustomDescription',
8
- },
9
+ Description: '/path/to/CustomDescription',
10
+ edit: {
11
+ SaveButton: '/path/to/CustomSaveButton',
12
+ },
13
},
14
},
15
}

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.

1
// collections/Posts.ts
2
import type { CollectionConfig } from 'payload'
3
4
export const PostsCollection: CollectionConfig = {
5
slug: 'posts',
6
- forceSelect: {
7
- title: true,
8
- slug: true,
9
- },
10
+ select: ({ select }) => (select ? { ...select, title: true, slug: true } : undefined),
11
fields: [],
12
}

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.

1
// collections/Posts.ts
2
import type { CollectionConfig } from 'payload'
3
4
export const PostsCollection: CollectionConfig = {
5
slug: 'posts',
6
admin: {
7
- hideAPIURL: true,
8
+ components: {
9
+ views: {
10
+ edit: {
11
+ api: {
12
+ tab: {
13
+ condition: () => false,
14
+ },
15
+ },
16
+ },
17
+ },
18
+ },
19
},
20
fields: [],
21
}

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

Column

@payloadcms/ui

payload

ListViewSlots

@payloadcms/ui

payload

ListViewClientProps

@payloadcms/ui

payload

EntityType

@payloadcms/ui/shared

payload/shared

formatAdminURL

@payloadcms/ui/shared

payload/shared

mergeListSearchAndWhere

@payloadcms/ui/shared

payload/shared

mergeHeaders

@payloadcms/next/utilities

payload

headersWithCors

@payloadcms/next/utilities

payload

createPayloadRequest

@payloadcms/next/utilities

createPayloadRequestFromWebRequest in payload

addDataAndFileToRequest

@payloadcms/next/utilities

payload

sanitizeLocales

@payloadcms/next/utilities

payload

addLocalesToRequestFromData

@payloadcms/next/utilities

payload

Renamed types — use the new canonical name from payload:

Old name

New name

Source

ListPreferences

CollectionPreferences

payload

ListComponentClientProps

ListViewClientProps

payload

ListComponentServerProps

ListViewServerProps

payload

1
- import type { Column, ListViewSlots, ListPreferences } from '@payloadcms/ui'
2
- import { EntityType, formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
3
- import { headersWithCors, mergeHeaders } from '@payloadcms/next/utilities'
4
+ import type { Column, ListViewSlots, CollectionPreferences } from 'payload'
5
+ import { EntityType, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared'
6
+ import { headersWithCors, mergeHeaders } from 'payload'

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.

1
- import type { TextFieldClientComponent } from 'payload'
2
+ import type { TextFieldClientProps } from 'payload'
3
+ import type React from 'react'
4
5
- export const CustomTextField: TextFieldClientComponent = (props) => {
6
+ export const CustomTextField: React.FC<TextFieldClientProps> = (props) => {
7
// ...
8
}

Generic type arguments move to the props type:

1
- import type { FieldErrorClientComponent, TextFieldClient } from 'payload'
2
+ import type { FieldErrorClientProps, TextFieldClient } from 'payload'
3
+ import type React from 'react'
4
5
- const Error: FieldErrorClientComponent<TextFieldClient> = (props) => null
6
+ const Error: React.FC<FieldErrorClientProps<TextFieldClient>> = (props) => null

Function declarations can annotate their parameter without a component wrapper:

1
import type { TextFieldServerProps } from 'payload'
2
3
export default async function CustomTextField(props: TextFieldServerProps) {
4
// ...
5
}

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:

1
npx @payloadcms/codemod --transform migrate-field-component-types

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

comparisonValue

valueFrom

Field value from the version being compared from

versionValue

valueTo

Field value from the version being compared to

1
import type { TextFieldDiffClientProps } from 'payload'
2
import type React from 'react'
3
4
export const MyTextDiff: React.FC<TextFieldDiffClientProps> = ({
5
- comparisonValue,
6
- versionValue,
7
+ valueFrom,
8
+ valueTo,
9
}) => {
10
// ...
11
}

Codemod

To migrate automatically, there's a codemod for this change available by running:

1
npx @payloadcms/codemod --transform rename-diff-value-props

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 defaultValue functions can now be typed as async without a TypeScript error. This was always safe at runtime — only the type was too strict.
  • If you import DefaultValue to type your own function or variable and then call it synchronously assuming the result is never a Promise, you'll need to update that code to handle the Promise branch.
1
import type { DefaultValue } from 'payload'
2
3
- const resolveDefault = (fn: DefaultValue, args: Args) => {
4
- const value = fn(args)
5
- return value.toString()
6
- }
7
+ const resolveDefault = async (fn: DefaultValue, args: Args) => {
8
+ const value = await fn(args)
9
+ return value.toString()
10
+ }

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:

1
const useAPIKey = collection.auth.useAPIKey ?? false

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:

1
const autoLogin = payload.config.admin.autoLogin
2
const presets = payload.config.queryPresets?.constraints

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:

1
const views = global.admin.components?.views
2
const interfaceName = global.typescript?.interface

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 (what TypedUser used to be). Without generated types, it falls back to a documented shape containing Payload's built-in auth fields.
  • AuthenticatedUser — User plus the optional runtime auth fields _strategy and _sid. This is what req.user, payload.auth(), auth strategies, me responses, and useAuth().user return.

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:

  • TypedUser is removed — replace it with User.
  • UntypedUser is removed — use User for a user document or AuthenticatedUser for the signed-in user.
  • ClientUser is removed — replace it with AuthenticatedUser for useAuth().user, me responses, and other client auth APIs.
  • User and AuthenticatedUser no longer have a { [key: string]: any } index signature. Custom auth-collection fields require generated types or an explicit augmented type or cast.
  • req.user and payload.auth().user are now typed as AuthenticatedUser, so _strategy and _sid type-check.
  • The deprecated top-level strategy field is removed from me and refresh responses (REST, GraphQL, and the SDK). Read user._strategy instead.
  • @payloadcms/plugin-ecommerce's ClientUserWithCart is removed — replace it with UserWithCart.
  • MeOperationResult.user and refreshCookieAsync() can be null, matching their existing runtime behavior. refreshCookieAsync() also preserves the custom user type passed to useAuth<T>().
  • Collection admin.hidden callbacks now receive PayloadRequest['user'], including null when there is no authenticated user.
  • The Local API user option is now User | null instead of the loose Document type for collection count, create, delete, duplicate, find, findByID, findDistinct, and update; collection and global version operations; and global findOne and update.
  • UserSession.createdAt is 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.

1
- import type { ClientUser, TypedUser, UntypedUser } from 'payload'
2
+ import type { AuthenticatedUser, User } from 'payload'
3
4
- const user: TypedUser = req.user
5
+ const user: AuthenticatedUser = req.user
6
7
- type CurrentUser = ClientUser
8
+ type CurrentUser = AuthenticatedUser

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):

1
import type { AuthenticatedUser } from 'payload'
2
3
// Your plugin adds a `cart` join field (or any custom field) to the user collection.
4
type UserWithCart = AuthenticatedUser & {
5
cart?: { docs?: (Cart | string)[] } | null
6
}
7
8
const user = req.user as null | UserWithCart
9
user?.cart?.docs // now typed

If the field name is dynamic (configurable at runtime), index through Record<string, unknown> instead:

1
const value = (user as Record<string, unknown>)[fieldNameFromConfig]

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.

1
- multiTenantPlugin<{ user: MyUser }>({ ... })
2
+ multiTenantPlugin({ ... })

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:

  • slug is now required. It registers the plugin in the plugins map for cross-plugin discovery. Add one to every definePlugin call.
  • Options are a named options argument. They were previously spread into the plugin callback's args alongside config and plugins; they're now under a single options property. TOptions is also no longer constrained to Record<string, unknown>, so interface and generic option types work.
1
export const myPlugin = definePlugin<MyPluginOptions>({
2
- // slug was optional
3
+ slug: 'my-plugin',
4
- plugin: ({ config, plugins, collections }) => ({ ...config }),
5
+ plugin: ({ config, options }) => ({ ...config }),
6
})

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

import { useRouter } from 'next/navigation'

import { useRouter } from '@payloadcms/ui'

import { usePathname } from 'next/navigation'

import { usePathname } from '@payloadcms/ui'

import { useSearchParams } from 'next/navigation'

import { useSearchParams } from '@payloadcms/ui'

import { useParams } from 'next/navigation'

import { useParams } from '@payloadcms/ui'

Link component — use PayloadLink from @payloadcms/ui:

1
- import Link from 'next/link'
2
+ import { PayloadLink as Link } from '@payloadcms/ui'

Types — LinkProps from next/link is replaced by LinkAdapterProps from payload:

1
- import type { LinkProps } from 'next/link'
2
+ import type { 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:

1
type RouterAdapterRouter = {
2
back: () => void
3
push: (path: string, options?: { scroll?: boolean }) => void
4
refresh: () => void
5
replace: (path: string, options?: { scroll?: boolean }) => void
6
}

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:

1
+ import { NextRouterAdapter } from '@payloadcms/next/elements/RouterAdapter'
2
3
<RootProvider
4
+ RouterAdapter={NextRouterAdapter}
5
config={clientConfig}
6
/* ... */
7
/>

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

DefaultTemplate

@payloadcms/next/templates

@payloadcms/ui/rsc

MinimalTemplate

@payloadcms/next/templates

@payloadcms/ui/rsc

CollectionCards

@payloadcms/next/rsc

@payloadcms/ui/rsc

DefaultNav

@payloadcms/next/rsc

@payloadcms/ui/rsc

DocumentHeader

@payloadcms/next/rsc

@payloadcms/ui/rsc

HierarchyTypeFieldServer

@payloadcms/next/rsc

@payloadcms/ui/rsc

Logo

@payloadcms/next/rsc

@payloadcms/ui/rsc

DefaultNavClient

@payloadcms/next/client

@payloadcms/ui

HierarchyTypeField

@payloadcms/next/client

@payloadcms/ui

NavSidebarToggle

@payloadcms/next/client

@payloadcms/ui

NavWrapper

@payloadcms/next/client

@payloadcms/ui

QueryPresetsAccessCell

@payloadcms/next/client

@payloadcms/ui

QueryPresetsColumnField

@payloadcms/next/client

@payloadcms/ui

QueryPresetsColumnsCell

@payloadcms/next/client

@payloadcms/ui

QueryPresetsGroupByCell

@payloadcms/next/client

@payloadcms/ui

QueryPresetsGroupByField

@payloadcms/next/client

@payloadcms/ui

QueryPresetsWhereCell

@payloadcms/next/client

@payloadcms/ui

QueryPresetsWhereField

@payloadcms/next/client

@payloadcms/ui

SlugField

@payloadcms/next/client

@payloadcms/ui

1
- import { DefaultTemplate, MinimalTemplate } from '@payloadcms/next/templates'
2
- import { DocumentHeader, Logo } from '@payloadcms/next/rsc'
3
- import { HierarchyTypeField, SlugField } from '@payloadcms/next/client'
4
+ import { DefaultTemplate, DocumentHeader, Logo, MinimalTemplate } from '@payloadcms/ui/rsc'
5
+ import { HierarchyTypeField, SlugField } from '@payloadcms/ui'

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.

1
'use client'
2
- import { useDocumentInfo } from '@payloadcms/ui'
3
+ import { useDocumentTitle } from '@payloadcms/ui'
4
5
const Heading: React.FC = () => {
6
- const { title, setDocumentTitle } = useDocumentInfo()
7
+ const { title, setDocumentTitle } = useDocumentTitle()
8
return <h2>{title}</h2>
9
}

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:

1
'use client'
2
import { useLocale } from '@payloadcms/ui'
3
4
const Greeting: React.FC = () => {
5
- const { code } = useLocale()
6
+ const locale = useLocale()
7
+
8
+ if (!locale) {
9
+ return null
10
+ }
11
+
12
+ const { code } = locale
13
14
return <span>{code}</span>
15
}

When a missing locale is already valid for the receiving API, optional chaining is sufficient:

1
const locale = useLocale()?.code

<<<<<<< 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:

1
`diff
2
'use client'
3
import type { TextFieldClientComponent } from 'payload'
4
import { useField } from '@payloadcms/ui'
5
6
-export const MyField: TextFieldClientComponent = ({ path }) => {
7
- const { readOnly, value } = useField({ path })
8
+export const MyField: TextFieldClientComponent = ({ path, readOnly }) => {
9
+ const { value } = useField({ path })
10
11
### Deprecated `TableColumnsProvider` props removed
12
13
The following deprecated props have been removed from `TableColumnsProvider` and its `TableColumnsProviderProps` type:
14
15
- `docs`
16
- `enableRowSelections`
17
- `listPreferences`
18
- `preferenceKey`
19
- `renderRowTypes`
20
- `setTable`
21
- `sortColumnProps`
22
- `tableAppearance`
23
24
`TableColumnsProvider` has ignored these props since 3.26.0, when column state moved to the URL. Remove them where you render the provider. It now only accepts `children`, `collectionSlug`, `columnState`, and `LinkedCellOverride`.
25
26
```diff
27
<TableColumnsProvider
28
collectionSlug={collectionSlug}
29
columnState={columnState}
30
- enableRowSelections
31
- preferenceKey={preferenceKey}
32
- tableAppearance="condensed"
33
>
34
{children}
35
</TableColumnsProvider>
36
`

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:

1
// my-richtext-adapter/index.ts
2
export const myRichTextAdapter = (): RichTextAdapterProvider => {
3
return ({ config }) => {
4
const myTranslations = { en: { myKey: 'My translation' } }
5
6
- return {
7
- // ... other adapter properties
8
- i18n: myTranslations,
9
- }
10
+ config.i18n.translations = deepMergeSimple(config.i18n.translations, myTranslations)
11
+ return {
12
+ // ... other adapter properties
13
+ }
14
}
15
}

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:

1
- const sanitizedConfig = await sanitizeConfig(config)
2
+ const sanitizedConfig = sanitizeConfig(config)

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:

1
- richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>>
2
+ richTextSanitizers: Array<(config: SanitizedConfig) => void>

Custom rich-text adapter providers and Lexical server feature callbacks must also return their value synchronously:

1
export const MyFeature = createServerFeature({
2
key: 'my-feature',
3
- feature: async () => ({
4
+ feature: () => ({
5
ClientFeature: '@my-package/client#MyFeature',
6
sanitizedServerFeatureProps: null,
7
}),
8
})

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:

1
const context = await initAdminContext(args)
2
3
-const userAgent = context.headers.get('user-agent')
4
+const userAgent = context.req.headers.get('user-agent')
5
6
-const languageCode = context.languageCode
7
+const languageCode = context.req.i18n.language

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:

1
npx @payloadcms/codemod --transform migrate-admin-context-properties

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:

1
export const MyTextDiff: React.FC<TextFieldDiffClientProps> = ({
2
- diffMethod,
3
valueFrom,
4
valueTo,
5
}) => {

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':

1
const afterOperation: CollectionAfterOperationHook = ({ operation, result }) => {
2
- if (operation === 'read') {
3
+ if (operation === 'find' || operation === 'findByID') {
4
// ...
5
}
6
7
return result
8
}

'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 (toCSV)

New (hooks.beforeExport)

row

siblingData

doc

data

data

(removed — was alias for row)

—

format (new: 'csv' | 'json')

The fromCSV → hooks.beforeImport change is non-breaking for the data parameter — both receive the full flat row.

1
{
2
name: 'author',
3
type: 'relationship',
4
relationTo: 'users',
5
custom: {
6
'plugin-import-export': {
7
- toCSV: ({ value, columnName, row, doc }) => {
8
- row[`${columnName}_id`] = (value as any).id
9
- return doc.title
10
- },
11
- fromCSV: ({ value, data }) => data[`${value}_key`],
12
+ hooks: {
13
+ beforeExport: ({ value, columnName, siblingData, data }) => {
14
+ siblingData[`${columnName}_id`] = (value as any).id
15
+ return data.title
16
+ },
17
+ beforeImport: ({ value, data }) => data[`${value}_key`],
18
+ },
19
},
20
},
21
}

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

@payloadcms/drizzle/types

@payloadcms/drizzle

@payloadcms/db-postgres/types

@payloadcms/db-postgres

@payloadcms/db-sqlite/types

@payloadcms/db-sqlite

@payloadcms/db-vercel-postgres/types

@payloadcms/db-vercel-postgres

@payloadcms/db-d1-sqlite/types

@payloadcms/db-d1-sqlite

1
- import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
2
- import type { PostgresAdapter } from '@payloadcms/db-postgres/types'
3
- import type { SQLiteAdapter } from '@payloadcms/db-sqlite/types'
4
- import type { VercelPostgresAdapter } from '@payloadcms/db-vercel-postgres/types'
5
- import type { SQLiteD1Adapter } from '@payloadcms/db-d1-sqlite/types'
6
+ import type { DrizzleAdapter } from '@payloadcms/drizzle'
7
+ import type { PostgresAdapter } from '@payloadcms/db-postgres'
8
+ import type { SQLiteAdapter } from '@payloadcms/db-sqlite'
9
+ import type { VercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
10
+ import type { SQLiteD1Adapter } from '@payloadcms/db-d1-sqlite'

If you use a declare module augmentation to extend GeneratedDatabaseSchema (typically found in generated schema files), update the module path as well:

1
- declare module '@payloadcms/db-postgres/types' {
2
+ declare module '@payloadcms/db-postgres' {
3
export interface GeneratedDatabaseSchema {
4
schema: DatabaseSchema
5
}
6
}

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:

1
- import { findMigrationDir } from '@payloadcms/drizzle'
2
+ import { findMigrationDir } from 'payload'

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-slate package
  • The Slate → Lexical migration utilities in @payloadcms/richtext-lexical/migrate
  • The payload-plugin-lexical migration 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.

1
- import { HTMLConverterFeature, lexicalHTML, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
2
+ import { lexicalHTMLField, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
3
4
editor: lexicalEditor({
5
features: ({ defaultFeatures }) => [
6
...defaultFeatures,
7
- HTMLConverterFeature({}),
8
],
9
}),
10
11
fields: [
12
{ name: 'content', type: 'richText' },
13
- lexicalHTML('content', { name: 'content_html' }),
14
+ lexicalHTMLField({ htmlFieldName: 'content_html', lexicalFieldName: 'content' }),
15
]

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.

1
- import { isHTMLElement } from '@payloadcms/richtext-lexical/client'
2
+ import { isHTMLElement } from 'lexical'

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):

1
- $convertFromMarkdownString(markdown, transformers)
2
+ $convertFromMarkdownString(markdown, transformers, undefined, false, true)

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:

1
- import { $convertFromMarkdownString, TRANSFORMERS } from '@payloadcms/richtext-lexical/lexical/markdown'
2
- $convertFromMarkdownString(markdown, TRANSFORMERS)
3
+ import { convertMarkdownToLexical } from '@payloadcms/richtext-lexical'
4
+ convertMarkdownToLexical({ editorConfig, markdown })

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_COMMAND now 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>, and SerializedRootNode<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's TypedEditorState<T> instead. Without a type argument, Lexical's children are still typed as SerializedLexicalNode[].
  • ReturnType<Node['exportJSON']> can now infer partial JSON, making fields such as version and url optional. If your code expects the full format, use the node's explicit serialized type, such as SerializedLinkNode. A normal node.exportJSON() call still returns the full format.
  • LexicalNode.getCommonAncestor was 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.

1
- import { EXPERIMENTAL_TableFeature } from '@payloadcms/richtext-lexical'
2
+ import { TableFeature } from '@payloadcms/richtext-lexical'
3
4
editor: lexicalEditor({
5
- features: ({ rootFeatures }) => [...rootFeatures, EXPERIMENTAL_TableFeature()],
6
+ features: ({ rootFeatures }) => [...rootFeatures, TableFeature()],
7
})

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.

1
`diff
2
- import type { LexicalEditorProps } from '@payloadcms/richtext-lexical'
3
+ import type { LexicalEditorArgs } from '@payloadcms/richtext-lexical'
4
5
### Lexical: `UploadDataImproved` replaced by `UploadData` ([#18606](https://github.com/payloadcms/payload/pull/18606))
6
7
The internal `UploadDataImproved` type has been removed, and its definition is now used by the public `UploadData` type. Replace any `UploadDataImproved` imports and type references with `UploadData`:
8
9
```ts
10
import type { UploadData } from '@payloadcms/richtext-lexical'
11
`

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 data object (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:

1
import { upgradeLexicalData } from '@payloadcms/richtext-lexical'
2
3
await upgradeLexicalData({ payload })

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.

1
{
2
name: 'tags',
3
type: 'json',
4
- typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
5
+ jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
6
}

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.

1
myAdapter: RichTextAdapter = {
2
- outputSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => {
3
+ jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired, typeStringDefinitions }) => {
4
// return JSONSchema4
5
},
6
}

@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.

1
- const schema = configToJSONSchema(sanitizedConfig, 'text')
2
+ const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')

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.

1
- fieldsToJSONSchema(
2
- collectionIDFieldTypes,
3
- fields,
4
- interfaceNameDefinitions,
5
- config,
6
- i18n,
7
- { forceInlineBlocks: true },
8
- )
9
+ fieldsToJSONSchema({
10
+ collectionIDFieldTypes,
11
+ config,
12
+ fields,
13
+ forceInlineBlocks: true,
14
+ i18n,
15
+ interfaceNameDefinitions,
16
+ typeStringDefinitions,
17
+ })

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.

1
entityToJSONSchema(
2
config,
3
entity,
4
interfaceNameDefinitions,
5
defaultIDType,
6
+ typeStringDefinitions,
7
collectionIDFieldTypes,
8
i18n,
9
- { forceInlineBlocks: true },
10
+ true,
11
)

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: ... }):

1
export const MyFeature = createServerFeature({
2
feature: () => ({
3
- generatedTypes: {
4
- modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => {
5
- // ... mutate currentSchema or interfaceNameDefinitions ...
6
- return currentSchema
7
- },
8
- },
9
nodes: [
10
createNode({
11
node: MyNode,
12
+ jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => {
13
+ typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { type: 'my'; /* ... */ }`)
14
+ return elementNodeSchema({
15
+ nodeType: 'my',
16
+ tsType: `SerializedMyNode<${nodeUnionName}>`,
17
+ })
18
+ },
19
}),
20
],
21
}),
22
})

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:

1
createNode({
2
- node: MyExtendedHeadingNode,
3
+ node: { replace: HeadingNode, with: (node) => new MyExtendedHeadingNode(node.__tag) },
4
})

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.

1
export const Pages: CollectionConfig = {
2
slug: 'pages',
3
fields: [
4
{
5
name: 'layout',
6
type: 'blocks',
7
- blockReferences: ['hero', 'content'],
8
- blocks: [],
9
+ blocks: ['hero', 'content'],
10
},
11
],
12
}

Inline blocks continue to work as before:

1
{
2
name: 'layout',
3
type: 'blocks',
4
blocks: [HeroBlock, ContentBlock],
5
}

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):

1
import type {
2
SerializedParagraphNode,
3
SerializedTextNode,
4
TypedEditorState,
5
} from '@payloadcms/richtext-lexical'
6
7
- type MyNodes = SerializedParagraphNode | SerializedTextNode
8
+ type MyNodes = SerializedParagraphNode<MyNodes> | SerializedTextNode
9
10
function renderRichText(state: TypedEditorState<MyNodes>) {
11
// ...
12
}

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:

1
// payload.config.ts
2
{
3
typescript: {
4
schema: [
5
({ jsonSchema }) => {
6
- jsonSchema.definitions.MyType = { type: 'object', properties: { /* ... */ } }
7
+ jsonSchema.$defs.MyType = { type: 'object', properties: { /* ... */ } }
8
return jsonSchema
9
},
10
],
11
},
12
}

And update any $ref pointers you wrote by hand to point at $defs:

1
- { $ref: '#/definitions/posts' }
2
+ { $ref: '#/$defs/posts' }

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:

1
jobs: {
2
jobsCollectionOverrides: ({ defaultJobsCollection }) => ({
3
...defaultJobsCollection,
4
access: {
5
...defaultJobsCollection.access,
6
read: ({ req }) => Boolean(req.user?.roles?.includes('admin')),
7
},
8
admin: {
9
...defaultJobsCollection.admin,
10
hidden: false,
11
},
12
}),
13
}

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.

1
- import type { BaseJob } from 'payload'
2
+ import type { Job } from 'payload'
3
4
- function processJob(job: BaseJob) {
5
+ function processJob(job: Job) {
6
// ...
7
}

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.

1
- const processJob = (job: Job<false>) => {
2
+ const processJob = (job: Job) => {
3
// ...
4
}
5
6
- const workflow: WorkflowConfig<false> = {
7
+ const workflow: WorkflowConfig = {
8
slug: 'my-workflow',
9
// ...
10
}

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.

1
- import type { TaskType, WorkflowTypes } from 'payload'
2
+ import type { TaskSlug, WorkflowSlug } from 'payload'

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.
1
// Before
2
const myTask: TaskHandler<'MyTask'> = async ({ input }) => {
3
if (!input.id) {
4
return { state: 'failed', errorMessage: 'Missing id' }
5
}
6
return { state: 'succeeded', output: { ok: true } }
7
}
8
9
// After
10
const myTask: TaskHandler<'MyTask'> = async ({ input }) => {
11
if (!input.id) {
12
throw new Error('Missing id')
13
}
14
return { output: { ok: true } }
15
}

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 step
  • start-end/N — explicit range with step

The following forms now throw a TypeError at registration:

  • /N — missing prefix
  • M/N — numeric prefix (single number before /)

Migrate any affected cron expressions:

1
# Missing prefix — the leading `*` is now required (both fire every 10 minutes)
2
- cron: '/10 * * * *'
3
+ cron: '*/10 * * * *'
4
5
# Numeric prefix "every N starting from M" — make the range explicit (fires at 5, 20, 35, 50)
6
- cron: '5/15 * * * *'
7
+ cron: '5-59/15 * * * *'
8
9
# Numeric prefix that produces a single value — drop the redundant step (only fires at minute 30)
10
- cron: '30/30 * * * *'
11
+ cron: '30 * * * *'

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.

1
{
2
name: 'tags',
3
type: 'relationship',
4
relationTo: 'tags',
5
hasMany: true,
6
- min: 1,
7
- max: 5,
8
+ minRows: 1,
9
+ maxRows: 5,
10
}

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: true nested under a localized parent. The end behavior is unchanged (Payload's own code already uses fieldShouldBeLocalized), but field.localized is no longer deleted. Custom plugin or hook code that reads field.localized directly will now see true where it previously saw undefined.

How to migrate:

  • Remove the compatibility block 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.localized reads in custom code with fieldShouldBeLocalized({ field, parentIsLocalized }) from payload/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.defaultLocalePublishOption no 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.
1
export default buildConfig({
2
localization: {
3
defaultLocale: 'en',
4
- defaultLocalePublishOption: 'active',
5
locales: ['en', 'es'],
6
},
7
})

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.

1
import { s3Storage } from '@payloadcms/storage-s3'
2
import { buildConfig } from 'payload'
3
4
export default buildConfig({
5
- plugins: [
6
- s3Storage({ bucket: process.env.S3_BUCKET, collections: { media: true }, config: { region: process.env.S3_REGION } }),
7
- otherPlugin(),
8
- ],
9
+ plugins: [otherPlugin()],
10
+ storage: [
11
+ s3Storage({ bucket: process.env.S3_BUCKET, collections: { media: true }, config: { region: process.env.S3_REGION } }),
12
+ ],
13
})

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:

1
const response = await fetch('/api/upload-instructions', {
2
method: 'POST',
3
credentials: 'include',
4
headers: { 'Content-Type': 'application/json' },
5
body: JSON.stringify({
6
collectionSlug: 'media',
7
docPrefix: 'gallery',
8
filename: file.name,
9
filesize: file.size,
10
mimeType: file.type,
11
}),
12
})

For S3 and GCS, send the file using the returned HTTP request, then pass instructions.file unchanged when creating the document:

1
const instructions = await response.json()
2
3
if (instructions.type !== 'http') {
4
throw new Error(`Unsupported upload instruction: ${instructions.type}`)
5
}
6
7
const { url, ...request } = instructions.request
8
await fetch(url, { ...request, body: file })
9
10
const formData = new FormData()
11
formData.append('_payload', JSON.stringify({}))
12
formData.append('file', JSON.stringify(instructions.file))
13
14
await fetch('/api/media', {
15
method: 'POST',
16
credentials: 'include',
17
body: formData,
18
})

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:

1
- import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types'
2
+ import type { UploadInstructionsAccess } from 'payload'
3
4
- const access: ClientUploadsAccess = ({ req }) => Boolean(req.user)
5
+ const access: UploadInstructionsAccess = ({ req }) => Boolean(req.user)

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:

1
uploadInstructions: {
2
- access,
3
- generate,
4
+ enabled: Boolean(clientUploads),
5
+ generate: generateUploadInstructions({ access }),
6
}

useUploadHandlers().setUploadHandler was removed. Name custom client handlers and return the same name in a dispatch instruction:

1
import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client'
2
3
export const MyClientUploadHandler = createClientUploadHandler({
4
name: 'uploadToMyProvider',
5
handler: async ({ data, file }) => {
6
await uploadToMyProvider({ data, file })
7
},
8
})
9
10
uploadInstructions: {
11
enabled: true,
12
generate: ({ filename, filesize, mimeType }) => ({
13
file: {
14
uploadReference: {},
15
filename,
16
mimeType,
17
size: filesize,
18
},
19
type: 'dispatch',
20
name: 'uploadToMyProvider',
21
data: {},
22
}),
23
useInAdmin: true,
24
}

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:

1
- import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
2
3
- initClientUploads({
4
- clientHandler: '/MyClientUploadHandler',
5
- collections,
6
- config,
7
- enabled: true,
8
- extraClientHandlerProps: () => ({ option: 'value' }),
9
- serverHandler,
10
- serverHandlerPath: '/my-upload-endpoint',
11
- })
12
13
uploadInstructions: {
14
+ adminHandler: {
15
+ path: '/MyClientUploadHandler',
16
+ props: { option: 'value' },
17
+ },
18
+ enabled: Boolean(clientUploads),
19
+ endpoint: {
20
+ handler: serverHandler,
21
+ path: '/my-upload-endpoint',
22
+ },
23
generate,
24
+ useInAdmin: true,
25
}

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.

1
- import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
2
+ import { s3Storage } from '@payloadcms/storage-s3'
3
4
export default buildConfig({
5
storage: [
6
- uploadthingStorage({
7
- collections: { media: true },
8
- options: { token: process.env.UPLOADTHING_TOKEN },
9
- }),
10
+ s3Storage({
11
+ collections: { media: true },
12
+ bucket: process.env.S3_BUCKET,
13
+ config: { region: process.env.S3_REGION },
14
+ }),
15
],
16
})

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. https://example.com)

Allowed methods

GET,PUT,OPTIONS (add HEAD if reading blobs in-browser)

Allowed headers

* (or at minimum x-ms-*,content-type,content-length)

Exposed headers

*

Max age

3600

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:

1
azureStorage({
2
- clientUploads: { chunkLargeFiles: true },
3
+ clientUploads: true,
4
// ...
5
})

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:

  1. The top-level sharp config property has been removed. Passing a Sharp instance now happens through sharpTransformer({ sharp }) instead.
  2. Per-collection Sharp options — constructorOptions, formatOptions, resizeOptions, trimOptions, and withMetadata — have been removed from CollectionConfig['upload']. They're now authored through sharpTransformer({ collections }).
  • imageSizes has also been removed from CollectionConfig['upload']. Image sizes are now declared as variants on sharpTransformer({ collections: { <slug>: { variants } } }). The entries themselves (name, width, height, generateImageName, and so on) are unchanged. A collection that still sets upload.imageSizes fails the build.
  1. sharp is no longer a dependency of payload at all (previously a devDependency, not a runtime dependency). Install @payloadcms/transformer-sharp explicitly.
  2. The Sharp-specific types SharpDependency, ImageUploadFormatOptions, ImageUploadTrimOptions, and SharpImageSizeOptions are no longer exported from payload. Import SharpDependency from @payloadcms/transformer-sharp. For the option types, use SharpCollectionConfig['formatOptions'], SharpCollectionConfig['trimOptions'], and NonNullable<SharpCollectionConfig['variants']>[number] from the same package.
1
pnpm add @payloadcms/transformer-sharp
1
+ import { sharpTransformer } from '@payloadcms/transformer-sharp'
2
import { buildConfig } from 'payload'
3
4
export default buildConfig({
5
collections: [
6
{
7
slug: 'media',
8
upload: {
9
- resizeOptions: { width: 2048, height: 2048 },
10
- imageSizes: [{ name: 'thumbnail', width: 400, height: 300 }],
11
},
12
},
13
],
14
- sharp,
15
upload: {
16
+ transformers: [
17
+ sharpTransformer({
18
+ sharp,
19
+ collections: {
20
+ media: {
21
+ resizeOptions: { width: 2048, height: 2048 },
22
+ variants: [{ name: 'thumbnail', width: 400, height: 300 }],
23
+ },
24
+ },
25
+ }),
26
+ ],
27
},
28
})

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:

1
- buildConfig({ sharp: myCustomSharp })
2
+ sharpTransformer({ sharp: myCustomSharp })

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:

1
- doc.sizes?.thumbnail?.url
2
+ doc.variants?.thumbnail?.url
3
4
- where: { 'sizes.thumbnail.filename': { equals: 'photo-400x300.png' } }
5
+ where: { 'variants.thumbnail.filename': { equals: 'photo-400x300.png' } }
6
7
- select: { sizes: { thumbnail: true } }
8
+ select: { variants: { thumbnail: true } }

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:

1
# MongoDB
2
payload migrate:create --file @payloadcms/db-mongodb/sizes-to-variants
3
# Postgres
4
payload migrate:create --file @payloadcms/db-postgres/sizes-to-variants
5
# Vercel Postgres
6
payload migrate:create --file @payloadcms/db-vercel-postgres/sizes-to-variants
7
# SQLite
8
payload migrate:create --file @payloadcms/db-sqlite/sizes-to-variants
9
# Cloudflare D1
10
payload migrate:create --file @payloadcms/db-d1-sqlite/sizes-to-variants
11
12
payload migrate

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.read can run twice per request: once with req.fileTransform set to true to decide a possible transform, and once without it for an ordinary read. Keep access.read cheap, and use req.fileTransform to restrict transformed reads — see Access control.
  • Responses produced by a transformer always carry Payload's CORS headers, which modifyResponseHeaders can'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 hidden prefix field, including collections that never configured a prefix and collections where the plugin is disabled (enabled: false).
  • Anyone who set alwaysInsertFields explicitly — 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:create will generate a migration that adds a prefix column to every affected collection's table.

How to migrate:

  • Remove alwaysInsertFields from your storage adapter or cloudStoragePlugin config — it has no effect and no longer exists on the option types.
  • For Postgres, SQLite, or D1 projects, run payload migrate:create to generate the migration that adds the prefix column, then run payload migrate to 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.

1
- import type { FileSizeImproved } from 'payload'
2
+ import type { FileSize } from 'payload'

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.

1
{
2
name: 'thumbnail',
3
width: 400,
4
height: 300,
5
admin: {
6
- disableListColumn: true,
7
- disableGroupBy: true,
8
+ disabled: { column: true, groupBy: true },
9
},
10
}

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.

1
mongooseAdapter({
2
connectOptions: {
3
- useFacet: false,
4
},
5
})

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.

1
- import { baseAPIKeyFields } from 'payload'
2
+ import { createAPIKeyFields } from 'payload'
3
4
export const MyCollection = {
5
fields: [
6
- ...baseAPIKeyFields,
7
+ ...createAPIKeyFields(),
8
],
9
}

If you previously cloned or modified baseAPIKeyFields, pass field overrides to createAPIKeyFields() instead:

1
createAPIKeyFields({
2
apiKeyField: {
3
admin: {
4
components: { Field: './APIKeyField#APIKeyField' },
5
},
6
},
7
includeEnableAPIKey: false,
8
})

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 }.

1
mcpPlugin({
2
collections: {
3
- posts: { enabled: true },
4
- users: { enabled: { find: true } },
5
+ // posts is exposed automatically, no entry needed
6
+ users: {
7
+ tools: {
8
+ create: false,
9
+ delete: false,
10
+ getCollectionSchema: false,
11
+ update: false,
12
+ },
13
+ }, // find only
14
},
15
})

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.

1
mcpPlugin({
2
- mcp: {
3
- tools: [
4
- {
5
- name: 'getPostScores',
6
- description: 'Score recent posts',
7
- parameters: z.object({ since: z.string() }).shape,
8
- handler: async (args, req) => ({ content: [...] }),
9
- },
10
- ],
11
- },
12
+ tools: {
13
+ getPostScores: defineTool({
14
+ description: 'Score recent posts',
15
+ input: z.object({ since: z.string() }),
16
+ }).handler(async ({ input, req }) => ({ content: [...] })),
17
+ },
18
})

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 and
authorizedMCP.overrideAccess. The local API reads the user from req.user:

1
const result = await req.payload.find({
2
collection: 'posts',
3
req,
4
- overrideAccess: false,
5
+ overrideAccess: authorizedMCP.overrideAccess,
6
})

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.

1
defineCollectionTool({
2
description: 'Publish a draft post by ID.',
3
input: z.object({
4
id: z.string(),
5
}),
6
}).handler(async ({ slug, input, req }) => {
7
// slug is resolved from the tool call
8
})

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.

1
// arguments to a `createDocuments` tool call
2
3
// before
4
{ title: 'Hello', _status: 'draft', draft: true, depth: 2 }
5
6
// after
7
{
8
slug: 'posts',
9
documents: [{ data: { title: 'Hello' } }],
10
draft: true,
11
depth: 2,
12
}

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.

1
upload: {
2
pasteURL: {
3
allowList: [{ hostname: 'cdn.example.com', protocol: 'https' }],
4
},
5
}

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.

1
- mcpPlugin({ experimental: { tools: { auth: { enabled: true } } } })
2
+ mcpPlugin({
3
+ collections: {
4
+ users: {
5
+ tools: {
6
+ login: { access: () => true },
7
+ },
8
+ },
9
+ },
10
+ })

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 old
MCPAccessSettings 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 and
overrideAccess. 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.

1
mcpPlugin({
2
- overrideAuth: async (req, getDefaultMcpAccessSettings) => {
3
- return getDefaultMcpAccessSettings()
4
- },
5
+ overrideGetAuthorizedMCP: async ({ overrideAccess, pluginConfig, req }) => {
6
+ const user = await authenticateExternalRequest(req)
7
+ req.user = user ?? null
8
+
9
+ return {
10
+ items: req.user ? pluginConfig.items : [],
11
+ overrideAccess,
12
+ }
13
+ },
14
})

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:

1
- @import '~@payloadcms/ui/scss';
1
- @payloadcms/ui/scss
2
+ @payloadcms/ui/css
3
4
- @payloadcms/ui/scss/app.scss
5
+ @payloadcms/ui/css/app.css

Token renames. The --theme-* variables were replaced by semantic --color-* tokens. Update any custom CSS that referenced them:

1
.my-component {
2
- background-color: var(--theme-bg);
3
- color: var(--theme-text);
4
+ background-color: var(--color-bg);
5
+ color: var(--color-text);
6
}

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 public
confirm-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 new
finalizeOrder 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 as
subscriptions.list:

1
stripePlugin({
2
- rest: true,
3
+ rest: {
4
+ allowedMethods: ['subscriptions.list'],
5
+ },
6
})

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

  • apiKeyLast4 is a new persisted field on every auth.useAPIKey collection. Generate and run a schema migration. Legacy rows backfill on the next successful API-key authentication.
  • enableAPIKey is removed. Revoke by setting apiKey: null.
  • hasAPIKey (3.x virtual field) is removed. Use apiKeyLast4 and docPermissions.fields.apiKey.
  • Custom access.read on the generated apiKey field 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:

1
export default buildConfig({
2
upload: {
3
+ abortOnLimit: false,
4
limits: {
5
fileSize: 5000000,
6
},
7
},
8
})

Was this page helpful?

Next

Versioning and Breaking Changes Policy