@payloadcms/codemod 4.0.0-internal.b8a92d2 → 4.0.0-internal.bf4bcce

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/README.md +97 -30
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.help.d.ts +4 -0
  4. package/dist/cli.help.d.ts.map +1 -0
  5. package/dist/cli.help.js +29 -0
  6. package/dist/cli.help.js.map +1 -0
  7. package/dist/cli.js +49 -27
  8. package/dist/cli.js.map +1 -1
  9. package/dist/cli.parseFlags.d.ts +15 -0
  10. package/dist/cli.parseFlags.d.ts.map +1 -1
  11. package/dist/cli.parseFlags.js +35 -2
  12. package/dist/cli.parseFlags.js.map +1 -1
  13. package/dist/registry.d.ts.map +1 -1
  14. package/dist/registry.js +16 -0
  15. package/dist/registry.js.map +1 -1
  16. package/dist/runbook/payload-v4-upgrade.md +55 -0
  17. package/dist/runbook/v4.mdx +2516 -0
  18. package/dist/transforms/add-override-access-true/basic.input.js +41 -0
  19. package/dist/transforms/add-override-access-true/basic.input.js.map +1 -0
  20. package/dist/transforms/add-override-access-true/basic.output.js +50 -0
  21. package/dist/transforms/add-override-access-true/basic.output.js.map +1 -0
  22. package/dist/transforms/add-override-access-true/comment-between-properties.input.js +11 -0
  23. package/dist/transforms/add-override-access-true/comment-between-properties.input.js.map +1 -0
  24. package/dist/transforms/add-override-access-true/comment-between-properties.output.js +12 -0
  25. package/dist/transforms/add-override-access-true/comment-between-properties.output.js.map +1 -0
  26. package/dist/transforms/add-override-access-true/index.d.ts +20 -0
  27. package/dist/transforms/add-override-access-true/index.d.ts.map +1 -0
  28. package/dist/transforms/add-override-access-true/index.js +208 -0
  29. package/dist/transforms/add-override-access-true/index.js.map +1 -0
  30. package/dist/transforms/add-override-access-true/javascript.input.d.ts +6 -0
  31. package/dist/transforms/add-override-access-true/javascript.input.d.ts.map +1 -0
  32. package/dist/transforms/add-override-access-true/javascript.input.js +24 -0
  33. package/dist/transforms/add-override-access-true/javascript.input.js.map +1 -0
  34. package/dist/transforms/add-override-access-true/javascript.output.d.ts +6 -0
  35. package/dist/transforms/add-override-access-true/javascript.output.d.ts.map +1 -0
  36. package/dist/transforms/add-override-access-true/javascript.output.js +26 -0
  37. package/dist/transforms/add-override-access-true/javascript.output.js.map +1 -0
  38. package/dist/transforms/add-override-access-true/non-matching.input.js +42 -0
  39. package/dist/transforms/add-override-access-true/non-matching.input.js.map +1 -0
  40. package/dist/transforms/add-override-access-true/non-matching.output.js +42 -0
  41. package/dist/transforms/add-override-access-true/non-matching.output.js.map +1 -0
  42. package/dist/transforms/add-override-access-true/single-line.input.js +13 -0
  43. package/dist/transforms/add-override-access-true/single-line.input.js.map +1 -0
  44. package/dist/transforms/add-override-access-true/single-line.output.js +17 -0
  45. package/dist/transforms/add-override-access-true/single-line.output.js.map +1 -0
  46. package/dist/transforms/add-override-access-true/trailing-comment.input.js +10 -0
  47. package/dist/transforms/add-override-access-true/trailing-comment.input.js.map +1 -0
  48. package/dist/transforms/add-override-access-true/trailing-comment.output.js +12 -0
  49. package/dist/transforms/add-override-access-true/trailing-comment.output.js.map +1 -0
  50. package/dist/transforms/migrate-admin-context-properties/basic.input.js +32 -0
  51. package/dist/transforms/migrate-admin-context-properties/basic.input.js.map +1 -0
  52. package/dist/transforms/migrate-admin-context-properties/basic.output.js +30 -0
  53. package/dist/transforms/migrate-admin-context-properties/basic.output.js.map +1 -0
  54. package/dist/transforms/migrate-admin-context-properties/index.d.ts +3 -0
  55. package/dist/transforms/migrate-admin-context-properties/index.d.ts.map +1 -0
  56. package/dist/transforms/migrate-admin-context-properties/index.js +302 -0
  57. package/dist/transforms/migrate-admin-context-properties/index.js.map +1 -0
  58. package/dist/transforms/migrate-admin-context-properties/no-match.input.js +30 -0
  59. package/dist/transforms/migrate-admin-context-properties/no-match.input.js.map +1 -0
  60. package/dist/transforms/migrate-admin-context-properties/no-match.output.js +30 -0
  61. package/dist/transforms/migrate-admin-context-properties/no-match.output.js.map +1 -0
  62. package/dist/transforms/migrate-aliased-exports/index.d.ts.map +1 -1
  63. package/dist/transforms/migrate-aliased-exports/index.js +6 -5
  64. package/dist/transforms/migrate-aliased-exports/index.js.map +1 -1
  65. package/dist/transforms/migrate-aliased-exports/request.input.js +6 -0
  66. package/dist/transforms/migrate-aliased-exports/request.input.js.map +1 -0
  67. package/dist/transforms/migrate-aliased-exports/request.output.js +5 -0
  68. package/dist/transforms/migrate-aliased-exports/request.output.js.map +1 -0
  69. package/dist/transforms/migrate-authorship-default/already-set.input.js +15 -0
  70. package/dist/transforms/migrate-authorship-default/already-set.input.js.map +1 -0
  71. package/dist/transforms/migrate-authorship-default/already-set.output.js +15 -0
  72. package/dist/transforms/migrate-authorship-default/already-set.output.js.map +1 -0
  73. package/dist/transforms/migrate-authorship-default/basic.input.js +16 -0
  74. package/dist/transforms/migrate-authorship-default/basic.input.js.map +1 -0
  75. package/dist/transforms/migrate-authorship-default/basic.output.js +18 -0
  76. package/dist/transforms/migrate-authorship-default/basic.output.js.map +1 -0
  77. package/dist/transforms/migrate-authorship-default/build-config.input.js +32 -0
  78. package/dist/transforms/migrate-authorship-default/build-config.input.js.map +1 -0
  79. package/dist/transforms/migrate-authorship-default/build-config.output.js +35 -0
  80. package/dist/transforms/migrate-authorship-default/build-config.output.js.map +1 -0
  81. package/dist/transforms/migrate-authorship-default/globals.input.js +16 -0
  82. package/dist/transforms/migrate-authorship-default/globals.input.js.map +1 -0
  83. package/dist/transforms/migrate-authorship-default/globals.output.js +17 -0
  84. package/dist/transforms/migrate-authorship-default/globals.output.js.map +1 -0
  85. package/dist/transforms/migrate-authorship-default/index.d.ts +11 -0
  86. package/dist/transforms/migrate-authorship-default/index.d.ts.map +1 -0
  87. package/dist/transforms/migrate-authorship-default/index.js +141 -0
  88. package/dist/transforms/migrate-authorship-default/index.js.map +1 -0
  89. package/dist/transforms/migrate-authorship-default/non-matching.input.js +18 -0
  90. package/dist/transforms/migrate-authorship-default/non-matching.input.js.map +1 -0
  91. package/dist/transforms/migrate-authorship-default/non-matching.output.js +18 -0
  92. package/dist/transforms/migrate-authorship-default/non-matching.output.js.map +1 -0
  93. package/dist/transforms/migrate-authorship-default/satisfies.input.js +11 -0
  94. package/dist/transforms/migrate-authorship-default/satisfies.input.js.map +1 -0
  95. package/dist/transforms/migrate-authorship-default/satisfies.output.js +13 -0
  96. package/dist/transforms/migrate-authorship-default/satisfies.output.js.map +1 -0
  97. package/dist/transforms/migrate-displayed-field-value-args/basic.input.js +4 -0
  98. package/dist/transforms/migrate-displayed-field-value-args/basic.input.js.map +1 -0
  99. package/dist/transforms/migrate-displayed-field-value-args/basic.output.js +8 -0
  100. package/dist/transforms/migrate-displayed-field-value-args/basic.output.js.map +1 -0
  101. package/dist/transforms/migrate-displayed-field-value-args/index.d.ts +3 -0
  102. package/dist/transforms/migrate-displayed-field-value-args/index.d.ts.map +1 -0
  103. package/dist/transforms/migrate-displayed-field-value-args/index.js +84 -0
  104. package/dist/transforms/migrate-displayed-field-value-args/index.js.map +1 -0
  105. package/dist/transforms/migrate-displayed-field-value-args/namespace.input.js +4 -0
  106. package/dist/transforms/migrate-displayed-field-value-args/namespace.input.js.map +1 -0
  107. package/dist/transforms/migrate-displayed-field-value-args/namespace.output.js +8 -0
  108. package/dist/transforms/migrate-displayed-field-value-args/namespace.output.js.map +1 -0
  109. package/dist/transforms/migrate-displayed-field-value-args/no-match.input.js +4 -0
  110. package/dist/transforms/migrate-displayed-field-value-args/no-match.input.js.map +1 -0
  111. package/dist/transforms/migrate-displayed-field-value-args/no-match.output.js +4 -0
  112. package/dist/transforms/migrate-displayed-field-value-args/no-match.output.js.map +1 -0
  113. package/dist/transforms/migrate-field-component-types/basic.input.js +8 -0
  114. package/dist/transforms/migrate-field-component-types/basic.input.js.map +1 -0
  115. package/dist/transforms/migrate-field-component-types/basic.output.js +8 -0
  116. package/dist/transforms/migrate-field-component-types/basic.output.js.map +1 -0
  117. package/dist/transforms/migrate-field-component-types/index.d.ts +3 -0
  118. package/dist/transforms/migrate-field-component-types/index.d.ts.map +1 -0
  119. package/dist/transforms/migrate-field-component-types/index.js +620 -0
  120. package/dist/transforms/migrate-field-component-types/index.js.map +1 -0
  121. package/dist/transforms/migrate-payload-request-creation/aliases.input.js +8 -0
  122. package/dist/transforms/migrate-payload-request-creation/aliases.input.js.map +1 -0
  123. package/dist/transforms/migrate-payload-request-creation/aliases.output.js +31 -0
  124. package/dist/transforms/migrate-payload-request-creation/aliases.output.js.map +1 -0
  125. package/dist/transforms/migrate-payload-request-creation/collision.input.js +9 -0
  126. package/dist/transforms/migrate-payload-request-creation/collision.input.js.map +1 -0
  127. package/dist/transforms/migrate-payload-request-creation/comments.input.js +9 -0
  128. package/dist/transforms/migrate-payload-request-creation/comments.input.js.map +1 -0
  129. package/dist/transforms/migrate-payload-request-creation/comments.output.js +34 -0
  130. package/dist/transforms/migrate-payload-request-creation/comments.output.js.map +1 -0
  131. package/dist/transforms/migrate-payload-request-creation/empty.input.js +5 -0
  132. package/dist/transforms/migrate-payload-request-creation/empty.input.js.map +1 -0
  133. package/dist/transforms/migrate-payload-request-creation/empty.output.js +7 -0
  134. package/dist/transforms/migrate-payload-request-creation/empty.output.js.map +1 -0
  135. package/dist/transforms/migrate-payload-request-creation/evaluation-order.input.js +10 -0
  136. package/dist/transforms/migrate-payload-request-creation/evaluation-order.input.js.map +1 -0
  137. package/dist/transforms/migrate-payload-request-creation/expression.input.js +8 -0
  138. package/dist/transforms/migrate-payload-request-creation/expression.input.js.map +1 -0
  139. package/dist/transforms/migrate-payload-request-creation/expression.output.js +8 -0
  140. package/dist/transforms/migrate-payload-request-creation/expression.output.js.map +1 -0
  141. package/dist/transforms/migrate-payload-request-creation/getters.input.js +52 -0
  142. package/dist/transforms/migrate-payload-request-creation/getters.input.js.map +1 -0
  143. package/dist/transforms/migrate-payload-request-creation/index.d.ts +3 -0
  144. package/dist/transforms/migrate-payload-request-creation/index.d.ts.map +1 -0
  145. package/dist/transforms/migrate-payload-request-creation/index.js +236 -0
  146. package/dist/transforms/migrate-payload-request-creation/index.js.map +1 -0
  147. package/dist/transforms/migrate-payload-request-creation/internal.input.js +6 -0
  148. package/dist/transforms/migrate-payload-request-creation/internal.input.js.map +1 -0
  149. package/dist/transforms/migrate-payload-request-creation/internal.output.js +6 -0
  150. package/dist/transforms/migrate-payload-request-creation/internal.output.js.map +1 -0
  151. package/dist/transforms/migrate-payload-request-creation/non-matching.input.js +5 -0
  152. package/dist/transforms/migrate-payload-request-creation/non-matching.input.js.map +1 -0
  153. package/dist/transforms/migrate-payload-request-creation/populated.input.js +7 -0
  154. package/dist/transforms/migrate-payload-request-creation/populated.input.js.map +1 -0
  155. package/dist/transforms/migrate-payload-request-creation/populated.output.js +30 -0
  156. package/dist/transforms/migrate-payload-request-creation/populated.output.js.map +1 -0
  157. package/dist/transforms/migrate-payload-request-creation/shadowed.input.js +7 -0
  158. package/dist/transforms/migrate-payload-request-creation/shadowed.input.js.map +1 -0
  159. package/dist/transforms/migrate-payload-request-creation/shadowed.output.js +9 -0
  160. package/dist/transforms/migrate-payload-request-creation/shadowed.output.js.map +1 -0
  161. package/dist/transforms/migrate-payload-request-creation/shorthand.input.js +12 -0
  162. package/dist/transforms/migrate-payload-request-creation/shorthand.input.js.map +1 -0
  163. package/dist/transforms/migrate-payload-request-creation/shorthand.output.js +12 -0
  164. package/dist/transforms/migrate-payload-request-creation/shorthand.output.js.map +1 -0
  165. package/dist/transforms/migrate-payload-request-creation/unsupported.input.js +9 -0
  166. package/dist/transforms/migrate-payload-request-creation/unsupported.input.js.map +1 -0
  167. package/dist/transforms/migrate-sharp-to-transformer/basic.input.js +28 -0
  168. package/dist/transforms/migrate-sharp-to-transformer/basic.input.js.map +1 -0
  169. package/dist/transforms/migrate-sharp-to-transformer/basic.output.js +39 -0
  170. package/dist/transforms/migrate-sharp-to-transformer/basic.output.js.map +1 -0
  171. package/dist/transforms/migrate-sharp-to-transformer/existing-transformers.input.js +27 -0
  172. package/dist/transforms/migrate-sharp-to-transformer/existing-transformers.input.js.map +1 -0
  173. package/dist/transforms/migrate-sharp-to-transformer/existing-transformers.output.js +33 -0
  174. package/dist/transforms/migrate-sharp-to-transformer/existing-transformers.output.js.map +1 -0
  175. package/dist/transforms/migrate-sharp-to-transformer/index.d.ts +3 -0
  176. package/dist/transforms/migrate-sharp-to-transformer/index.d.ts.map +1 -0
  177. package/dist/transforms/migrate-sharp-to-transformer/index.js +430 -0
  178. package/dist/transforms/migrate-sharp-to-transformer/index.js.map +1 -0
  179. package/dist/transforms/migrate-sharp-to-transformer/no-match.input.js +11 -0
  180. package/dist/transforms/migrate-sharp-to-transformer/no-match.input.js.map +1 -0
  181. package/dist/transforms/migrate-sharp-to-transformer/no-match.output.js +11 -0
  182. package/dist/transforms/migrate-sharp-to-transformer/no-match.output.js.map +1 -0
  183. package/dist/transforms/migrate-versions-default/index.js +5 -5
  184. package/dist/transforms/migrate-versions-default/index.js.map +1 -1
  185. package/dist/transforms/remove-publish-specific-locale/index.d.ts.map +1 -1
  186. package/dist/transforms/remove-publish-specific-locale/index.js.map +1 -1
  187. package/dist/transforms/remove-storage-always-insert-fields/index.d.ts.map +1 -1
  188. package/dist/transforms/remove-storage-always-insert-fields/index.js +4 -0
  189. package/dist/transforms/remove-storage-always-insert-fields/index.js.map +1 -1
  190. package/dist/transforms/remove-versions-true/index.js +4 -4
  191. package/dist/transforms/remove-versions-true/index.js.map +1 -1
  192. package/dist/transforms/rename-diff-value-props/basic.input.js +38 -0
  193. package/dist/transforms/rename-diff-value-props/basic.input.js.map +1 -0
  194. package/dist/transforms/rename-diff-value-props/basic.output.js +38 -0
  195. package/dist/transforms/rename-diff-value-props/basic.output.js.map +1 -0
  196. package/dist/transforms/rename-diff-value-props/index.d.ts +3 -0
  197. package/dist/transforms/rename-diff-value-props/index.d.ts.map +1 -0
  198. package/dist/transforms/rename-diff-value-props/index.js +328 -0
  199. package/dist/transforms/rename-diff-value-props/index.js.map +1 -0
  200. package/dist/transforms/rename-diff-value-props/no-match.input.js +8 -0
  201. package/dist/transforms/rename-diff-value-props/no-match.input.js.map +1 -0
  202. package/dist/transforms/rename-experimental-table-feature/index.d.ts.map +1 -1
  203. package/dist/transforms/rename-experimental-table-feature/index.js +7 -1
  204. package/dist/transforms/rename-experimental-table-feature/index.js.map +1 -1
  205. package/dist/upgrade/defaultTag.d.ts +9 -0
  206. package/dist/upgrade/defaultTag.d.ts.map +1 -0
  207. package/dist/upgrade/defaultTag.js +25 -0
  208. package/dist/upgrade/defaultTag.js.map +1 -0
  209. package/dist/upgrade/detectPackageManager.d.ts +8 -0
  210. package/dist/upgrade/detectPackageManager.d.ts.map +1 -0
  211. package/dist/upgrade/detectPackageManager.js +38 -0
  212. package/dist/upgrade/detectPackageManager.js.map +1 -0
  213. package/dist/upgrade/dispatch.d.ts +38 -0
  214. package/dist/upgrade/dispatch.d.ts.map +1 -0
  215. package/dist/upgrade/dispatch.js +132 -0
  216. package/dist/upgrade/dispatch.js.map +1 -0
  217. package/dist/upgrade/index.d.ts +25 -0
  218. package/dist/upgrade/index.d.ts.map +1 -0
  219. package/dist/upgrade/index.js +209 -0
  220. package/dist/upgrade/index.js.map +1 -0
  221. package/dist/upgrade/prompt.d.ts +14 -0
  222. package/dist/upgrade/prompt.d.ts.map +1 -0
  223. package/dist/upgrade/prompt.js +77 -0
  224. package/dist/upgrade/prompt.js.map +1 -0
  225. package/dist/upgrade/report.d.ts +21 -0
  226. package/dist/upgrade/report.d.ts.map +1 -0
  227. package/dist/upgrade/report.js +47 -0
  228. package/dist/upgrade/report.js.map +1 -0
  229. package/dist/upgrade/resolveVersions.d.ts +13 -0
  230. package/dist/upgrade/resolveVersions.d.ts.map +1 -0
  231. package/dist/upgrade/resolveVersions.js +55 -0
  232. package/dist/upgrade/resolveVersions.js.map +1 -0
  233. package/dist/upgrade/rewritePackageJson.d.ts +32 -0
  234. package/dist/upgrade/rewritePackageJson.d.ts.map +1 -0
  235. package/dist/upgrade/rewritePackageJson.js +164 -0
  236. package/dist/upgrade/rewritePackageJson.js.map +1 -0
  237. package/dist/upgrade/runInstall.d.ts +16 -0
  238. package/dist/upgrade/runInstall.d.ts.map +1 -0
  239. package/dist/upgrade/runInstall.js +17 -0
  240. package/dist/upgrade/runInstall.js.map +1 -0
  241. package/dist/upgrade/selectAgent.d.ts +45 -0
  242. package/dist/upgrade/selectAgent.d.ts.map +1 -0
  243. package/dist/upgrade/selectAgent.js +54 -0
  244. package/dist/upgrade/selectAgent.js.map +1 -0
  245. package/dist/upgrade/selfCommand.d.ts +13 -0
  246. package/dist/upgrade/selfCommand.d.ts.map +1 -0
  247. package/dist/upgrade/selfCommand.js +17 -0
  248. package/dist/upgrade/selfCommand.js.map +1 -0
  249. package/dist/upgrade/types.d.ts +47 -0
  250. package/dist/upgrade/types.d.ts.map +1 -0
  251. package/dist/upgrade/types.js +11 -0
  252. package/dist/upgrade/types.js.map +1 -0
  253. package/dist/utils/project.d.ts +10 -0
  254. package/dist/utils/project.d.ts.map +1 -0
  255. package/dist/utils/project.js +43 -0
  256. package/dist/utils/project.js.map +1 -0
  257. package/package.json +8 -4
@@ -0,0 +1,2516 @@
1
+ ---
2
+ title: 3.0 to 4.0 Migration Guide
3
+ label: 3.0 to 4.0
4
+ order: 20
5
+ desc: Upgrade guide for Payload 3.x projects migrating to 4.0.
6
+ keywords: local api, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react
7
+ ---
8
+
9
+ 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.
10
+
11
+ ## Codemod
12
+
13
+ Most of the breaking changes below are auto-migratable using the [`@payloadcms/codemod`](https://github.com/payloadcms/payload/tree/main/packages/codemod) CLI:
14
+
15
+ ```bash
16
+ npx @payloadcms/codemod
17
+ ```
18
+
19
+ The codemod is idempotent and safe to run on a partially-migrated project.
20
+
21
+ ## Breaking Changes
22
+
23
+ ### Payload request creation is consolidated
24
+
25
+ Payload request creation now uses `createPayloadRequest` for local contexts and `createPayloadRequestFromWebRequest` to convert a Web `Request`. Admin context setup is exposed as `initAdminContext`.
26
+
27
+ | Old name | New name |
28
+ | --------------------------------------- | ------------------------------------------- |
29
+ | `createLocalReq` | `createPayloadRequest` |
30
+ | `CreateLocalReqOptions` (internal type) | `Omit<CreatePayloadRequestArgs, 'payload'>` |
31
+ | `createPayloadRequest` | `createPayloadRequestFromWebRequest` |
32
+ | `initReq` | `initAdminContext` |
33
+ | `InitReqArgs` | `InitAdminContextArgs` |
34
+ | `InitReqResult` | `AdminContext` |
35
+ | `InitReqCache` | `AdminContextCache` |
36
+ | `InitReqPartialResult` | `PartialAdminContext` |
37
+
38
+ `initAdminContext` and its cache types are primarily contracts for framework adapters. Custom Root layout adapters must rename their injected `initReq` callback to `initAdminContext`.
39
+
40
+ Local request creation now takes a single object argument containing `payload`. For a request without options:
41
+
42
+ ```diff
43
+ -import { createLocalReq } from 'payload'
44
+ +import { createPayloadRequest } from 'payload'
45
+
46
+ -const req = await createLocalReq({}, payload)
47
+ +const req = await createPayloadRequest({ payload })
48
+ ```
49
+
50
+ `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`.
51
+
52
+ Web request conversion uses its new role-specific name:
53
+
54
+ ```diff
55
+ -import { createPayloadRequest } from 'payload'
56
+ +import { createPayloadRequestFromWebRequest } from 'payload'
57
+
58
+ -const req = await createPayloadRequest({ config, request })
59
+ +const req = await createPayloadRequestFromWebRequest({ config, request })
60
+ ```
61
+
62
+ #### Codemod
63
+
64
+ To migrate automatically, there's a codemod for this change available by running:
65
+
66
+ ```bash
67
+ npx @payloadcms/codemod --transform migrate-payload-request-creation
68
+ ```
69
+
70
+ 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.
71
+
72
+ ### JWT authentication is versioned without moving token data
73
+
74
+ 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.
75
+
76
+ 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.
77
+
78
+ 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.
79
+
80
+ ```ts
81
+ // Decoded payload: unchanged paths
82
+ {
83
+ id: 'user-id',
84
+ collection: 'users',
85
+ sid: 'session-id',
86
+ email: 'user@example.com',
87
+ role: 'super-admin', // from saveToJWT
88
+ iat: 1750000000,
89
+ exp: 1750007200,
90
+ }
91
+
92
+ // Signed protected header
93
+ {
94
+ alg: 'HS256',
95
+ typ: 'JWT',
96
+ authVersion: 1,
97
+ }
98
+ ```
99
+
100
+ 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.
101
+
102
+ 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:
103
+
104
+ - Users must sign in again after deployment.
105
+ - Integrations must reissue stored bearer tokens.
106
+ - Expect a temporary spike in `401` responses until clients discard their stored tokens.
107
+
108
+ 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:
109
+
110
+ ```ts
111
+ import { decodeProtectedHeader } from 'jose'
112
+ import { JWT_AUTH_VERSION } from 'payload'
113
+
114
+ if (decodeProtectedHeader(token).authVersion !== JWT_AUTH_VERSION) {
115
+ throw new Error('Unsupported Payload token')
116
+ }
117
+ ```
118
+
119
+ A token is also rejected if its `collection` is not auth-enabled or has `disableLocalStrategy` enabled.
120
+
121
+ See [Token Data](../authentication/token-data) for the full token data shape.
122
+
123
+ ### Local API access is now enforced by default #overrideaccess-now-defaults-to-false-on-local-api-operations
124
+
125
+ `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.
126
+
127
+ ```diff
128
+ const posts = await payload.find({
129
+ collection: 'posts',
130
+ + overrideAccess: true, // preserves the Payload 3 default
131
+ })
132
+ ```
133
+
134
+ - `overrideAccess: false` (the new default) — respect Access Control. Use this whenever the operation acts on behalf of a user, and pass `user` alongside it.
135
+ - `overrideAccess: true` — bypass Access Control. Use this for trusted server-side work such as cron jobs, seeding, and migrations.
136
+
137
+ 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:
138
+
139
+ ```bash
140
+ npx @payloadcms/codemod
141
+ ```
142
+
143
+ 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.
144
+
145
+ #### Cases the codemod leaves behind
146
+
147
+ Run a typecheck after the codemod. Three patterns it cannot resolve:
148
+
149
+ - **Spread arguments** — `payload.find({ ...args })`. The codemod cannot tell whether `args` already supplies the property.
150
+ - **Aliased or cast receivers** — `const db = payload; db.find({ ... })` and `(payload as any).find({ ... })`. It matches `payload`, `.payload`, and their `.jobs` properties only.
151
+ - **Non-literal arguments** — `payload.find(options)`. The codemod cannot safely modify the value at the call site.
152
+
153
+ #### Jobs operations use the same default
154
+
155
+ `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.
156
+
157
+ ### On-demand validation adds a `validate` operation
158
+
159
+ The `Operation` type now includes `validate`. The `operation` argument for collection, global, and field `beforeValidate` and `beforeChange` hooks also includes `validate`.
160
+
161
+ 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`:
162
+
163
+ ```ts
164
+ import type { CollectionBeforeChangeHook } from 'payload'
165
+
166
+ const syncCustomer: CollectionBeforeChangeHook = async ({
167
+ data,
168
+ operation,
169
+ }) => {
170
+ if (operation === 'validate') {
171
+ return data
172
+ }
173
+
174
+ await syncCustomerWithStripe(data)
175
+
176
+ return data
177
+ }
178
+ ```
179
+
180
+ 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.
181
+
182
+ ### Access Control callbacks now receive document slugs
183
+
184
+ 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.
185
+
186
+ ### Custom bin scripts replaced by native CLI commands
187
+
188
+ 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.
189
+
190
+ In Payload 3.x, config entries connected a command name to a script path:
191
+
192
+ ```ts
193
+ // payload.config.ts
194
+ export default buildConfig({
195
+ bin: [
196
+ {
197
+ key: 'seed',
198
+ scriptPath: path.resolve(dirname, 'seed.ts'),
199
+ },
200
+ ],
201
+ })
202
+ ```
203
+
204
+ The imported module had to expose a specially named `script` function:
205
+
206
+ ```ts
207
+ // seed.ts
208
+ import type { SanitizedConfig } from 'payload'
209
+
210
+ import payload from 'payload'
211
+
212
+ export const script = async (config: SanitizedConfig) => {
213
+ await payload.init({ config })
214
+ // Seed the database...
215
+ process.exit(0)
216
+ }
217
+ ```
218
+
219
+ In Payload 4.0, define a real CLI command instead:
220
+
221
+ ```ts
222
+ // seed.ts
223
+ import { strictObject, z } from 'payload'
224
+ import { defineCLICommand } from 'payload/cli'
225
+
226
+ export const seedCommand = defineCLICommand({
227
+ description: 'Seed the database.',
228
+ input: strictObject({
229
+ clear: z
230
+ ._default(z.boolean(), false)
231
+ .check(z.describe('Delete existing documents first.')),
232
+ }),
233
+ handler: async ({ args, getPayload }) => {
234
+ const payload = await getPayload()
235
+
236
+ if (args.clear) {
237
+ // Delete existing documents...
238
+ }
239
+
240
+ // Seed the database...
241
+ payload.logger.info('Successfully seeded!')
242
+ },
243
+ })
244
+ ```
245
+
246
+ Register the command export in your Payload config:
247
+
248
+ ```ts
249
+ // payload.config.ts
250
+ export default buildConfig({
251
+ cli: {
252
+ commands: {
253
+ seed: './seed.js#seedCommand',
254
+ },
255
+ },
256
+ })
257
+ ```
258
+
259
+ When migrating custom scripts:
260
+
261
+ - Replace `bin` entries with entries in the `cli.commands` map. The map key becomes the command name.
262
+ - Replace the exported `script(config)` function with a schema-backed command created by `defineCLICommand`.
263
+ - 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.
264
+ - 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.
265
+ - Use the provided `getPayload()` function so Payload can shut down database connections after the command finishes.
266
+ - Return an exit code from the handler when needed. Do not call `process.exit()` from the command.
267
+
268
+ 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.
269
+
270
+ See the [Payload CLI documentation](../configuration/cli) for the complete API.
271
+
272
+ ### Legacy migration CLI export removed
273
+
274
+ 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.
275
+
276
+ If application code needs migration behavior rather than command-line parsing, call the database adapter directly through an initialized Payload instance:
277
+
278
+ ```ts
279
+ await payload.db.migrate()
280
+ ```
281
+
282
+ The adapter also exposes the corresponding `migrateDown`, `migrateFresh`, `migrateRefresh`, `migrateReset`, `migrateStatus`, and `createMigration` methods.
283
+
284
+ 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:
285
+
286
+ ```ts
287
+ import payload from 'payload'
288
+
289
+ const { code } = await payload.bin({
290
+ args: ['migrate:create', '--file', migrationPath],
291
+ cwd: projectDirectory,
292
+ })
293
+ ```
294
+
295
+ 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.
296
+
297
+ ### Generator helpers now return result objects
298
+
299
+ `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.
300
+
301
+ ### Default query depth has been reduced
302
+
303
+ Queries that omit `depth` now populate one relationship level instead of two. This reduces database work and response size by default.
304
+
305
+ If your application relies on two populated levels, prefer specifying `depth: 2` only on the queries that need it:
306
+
307
+ ```ts
308
+ const posts = await payload.find({
309
+ collection: 'posts',
310
+ overrideAccess: true,
311
+ depth: 2,
312
+ })
313
+ ```
314
+
315
+ To preserve the Payload 3.x behavior application-wide, set `defaultDepth: 2`:
316
+
317
+ ```diff
318
+ export default buildConfig({
319
+ + defaultDepth: 2,
320
+ })
321
+ ```
322
+
323
+ 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.
324
+
325
+ ### Versions are now enabled by default for all collections and globals
326
+
327
+ 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.
328
+
329
+ **What changes:**
330
+
331
+ - 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.
332
+ - 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.
333
+ - The bare `versions: true` you may have added previously is now redundant and can be removed.
334
+
335
+ **To opt out for an existing collection or global:**
336
+
337
+ ```diff
338
+ export const Posts: CollectionConfig = {
339
+ slug: 'posts',
340
+ fields: [],
341
+ + versions: false,
342
+ }
343
+ ```
344
+
345
+ **Two codemods are available** to automate this migration:
346
+
347
+ ```bash
348
+ # 1. Preserve existing behaviour: add versions: false everywhere versions is not set
349
+ npx @payloadcms/codemod --transform migrate-versions-default
350
+
351
+ # 2. Remove now-redundant versions: true (bare boolean) from your configs
352
+ npx @payloadcms/codemod --transform remove-versions-true
353
+ ```
354
+
355
+ 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.
356
+
357
+ Run `remove-versions-true` to clean up any bare `versions: true` that is now the default.
358
+
359
+ ### Version read access inherits collection and global read access
360
+
361
+ When `readVersions` is not configured, version access now inherits the Collection or Global `read` Access Control instead of requiring an authenticated user by default.
362
+
363
+ 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:
364
+
365
+ ```ts
366
+ export const Posts: CollectionConfig = {
367
+ slug: 'posts',
368
+ access: {
369
+ read: () => true,
370
+ readVersions: ({ req: { user } }) => Boolean(user),
371
+ },
372
+ fields: [],
373
+ }
374
+ ```
375
+
376
+ ### Authorship is now enabled by default
377
+
378
+ 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`.
379
+
380
+ **What changes:**
381
+
382
+ - 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`.
383
+ - `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.
384
+ - 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.
385
+
386
+ **Plugins:** plugin-injected collections also receive authorship unless the plugin opts out. In this release:
387
+
388
+ - **Opted out** (no authorship): `@payloadcms/plugin-search`, `@payloadcms/payload-cloud`, and all `@payloadcms/plugin-ecommerce` collections.
389
+ - **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.
390
+
391
+ **To opt out for an existing collection or global:**
392
+
393
+ ```diff
394
+ export const Posts: CollectionConfig = {
395
+ slug: 'posts',
396
+ fields: [],
397
+ + authorship: false,
398
+ }
399
+ ```
400
+
401
+ **A codemod is available** to preserve the previous behaviour:
402
+
403
+ ```bash
404
+ npx @payloadcms/codemod --transform migrate-authorship-default
405
+ ```
406
+
407
+ `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).
408
+
409
+ ### List View Select API is now the default
410
+
411
+ The `admin.enableListViewSelectAPI` Collection Config property has been removed. The List View now always uses the [Select API](../queries/select) to query only the active columns, which was previously opt-in.
412
+
413
+ 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.
414
+
415
+ ### Global component slots now match Collections
416
+
417
+ 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.
418
+
419
+ ```diff
420
+ export const Header: GlobalConfig = {
421
+ slug: 'header',
422
+ admin: {
423
+ components: {
424
+ - elements: {
425
+ - SaveButton: '/path/to/CustomSaveButton',
426
+ - Description: '/path/to/CustomDescription',
427
+ - },
428
+ + Description: '/path/to/CustomDescription',
429
+ + edit: {
430
+ + SaveButton: '/path/to/CustomSaveButton',
431
+ + },
432
+ },
433
+ },
434
+ }
435
+ ```
436
+
437
+ The available slots under `admin.components.edit` for Globals now match Collections (`beforeDocumentControls`, `editMenuItems`, `PreviewButton`, `PublishButton`, `SaveButton`, `SaveDraftButton`, `Status`, `UnpublishButton`). `Upload` remains Collection-only.
438
+
439
+ Globals also now support custom views with arbitrary keys under `admin.components.views`, mirroring Collections.
440
+
441
+ This change is auto-migrated by the `globals-components-edit` codemod.
442
+
443
+ ### Dynamic selection replaces static forced selection
444
+
445
+ 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.
446
+
447
+ 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.
448
+
449
+ ```diff
450
+ // collections/Posts.ts
451
+ import type { CollectionConfig } from 'payload'
452
+
453
+ export const PostsCollection: CollectionConfig = {
454
+ slug: 'posts',
455
+ - forceSelect: {
456
+ - title: true,
457
+ - slug: true,
458
+ - },
459
+ + select: ({ select }) => (select ? { ...select, title: true, slug: true } : undefined),
460
+ fields: [],
461
+ }
462
+ ```
463
+
464
+ The same change applies to Globals.
465
+
466
+ 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.
467
+
468
+ #### API tab visibility now uses a condition
469
+
470
+ `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.
471
+
472
+ ```diff
473
+ // collections/Posts.ts
474
+ import type { CollectionConfig } from 'payload'
475
+
476
+ export const PostsCollection: CollectionConfig = {
477
+ slug: 'posts',
478
+ admin: {
479
+ - hideAPIURL: true,
480
+ + components: {
481
+ + views: {
482
+ + edit: {
483
+ + api: {
484
+ + tab: {
485
+ + condition: () => false,
486
+ + },
487
+ + },
488
+ + },
489
+ + },
490
+ + },
491
+ },
492
+ fields: [],
493
+ }
494
+ ```
495
+
496
+ Run `npx @payloadcms/codemod --transform remove-hide-api-url` to migrate automatically.
497
+
498
+ ### Aliased UI and Next.js re-exports have been removed
499
+
500
+ 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.
501
+
502
+ **Pass-through re-exports** — same name, new source:
503
+
504
+ | Symbol | Old source | New source |
505
+ | ----------------------------- | ---------------------------- | ------------------------------------------------- |
506
+ | `Column` | `@payloadcms/ui` | `payload` |
507
+ | `ListViewSlots` | `@payloadcms/ui` | `payload` |
508
+ | `ListViewClientProps` | `@payloadcms/ui` | `payload` |
509
+ | `EntityType` | `@payloadcms/ui/shared` | `payload/shared` |
510
+ | `formatAdminURL` | `@payloadcms/ui/shared` | `payload/shared` |
511
+ | `mergeListSearchAndWhere` | `@payloadcms/ui/shared` | `payload/shared` |
512
+ | `mergeHeaders` | `@payloadcms/next/utilities` | `payload` |
513
+ | `headersWithCors` | `@payloadcms/next/utilities` | `payload` |
514
+ | `createPayloadRequest` | `@payloadcms/next/utilities` | `createPayloadRequestFromWebRequest` in `payload` |
515
+ | `addDataAndFileToRequest` | `@payloadcms/next/utilities` | `payload` |
516
+ | `sanitizeLocales` | `@payloadcms/next/utilities` | `payload` |
517
+ | `addLocalesToRequestFromData` | `@payloadcms/next/utilities` | `payload` |
518
+
519
+ **Renamed types** — use the new canonical name from `payload`:
520
+
521
+ | Old name | New name | Source |
522
+ | -------------------------- | ----------------------- | --------- |
523
+ | `ListPreferences` | `CollectionPreferences` | `payload` |
524
+ | `ListComponentClientProps` | `ListViewClientProps` | `payload` |
525
+ | `ListComponentServerProps` | `ListViewServerProps` | `payload` |
526
+
527
+ ```diff
528
+ - import type { Column, ListViewSlots, ListPreferences } from '@payloadcms/ui'
529
+ - import { EntityType, formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
530
+ - import { headersWithCors, mergeHeaders } from '@payloadcms/next/utilities'
531
+ + import type { Column, ListViewSlots, CollectionPreferences } from 'payload'
532
+ + import { EntityType, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared'
533
+ + import { headersWithCors, mergeHeaders } from 'payload'
534
+ ```
535
+
536
+ 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.
537
+
538
+ ### Field component types replaced by props types
539
+
540
+ 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.
541
+
542
+ ```diff
543
+ - import type { TextFieldClientComponent } from 'payload'
544
+ + import type { TextFieldClientProps } from 'payload'
545
+ + import type React from 'react'
546
+
547
+ - export const CustomTextField: TextFieldClientComponent = (props) => {
548
+ + export const CustomTextField: React.FC<TextFieldClientProps> = (props) => {
549
+ // ...
550
+ }
551
+ ```
552
+
553
+ Generic type arguments move to the props type:
554
+
555
+ ```diff
556
+ - import type { FieldErrorClientComponent, TextFieldClient } from 'payload'
557
+ + import type { FieldErrorClientProps, TextFieldClient } from 'payload'
558
+ + import type React from 'react'
559
+
560
+ - const Error: FieldErrorClientComponent<TextFieldClient> = (props) => null
561
+ + const Error: React.FC<FieldErrorClientProps<TextFieldClient>> = (props) => null
562
+ ```
563
+
564
+ Function declarations can annotate their parameter without a component wrapper:
565
+
566
+ ```tsx
567
+ import type { TextFieldServerProps } from 'payload'
568
+
569
+ export default async function CustomTextField(props: TextFieldServerProps) {
570
+ // ...
571
+ }
572
+ ```
573
+
574
+ This covers main field components, labels, descriptions, errors, diffs, and block row labels. Admin-view types such as `AdminViewComponent` and `DocumentTabComponent` are separate.
575
+
576
+ #### Codemod
577
+
578
+ To migrate automatically, there's a codemod for this change available by running:
579
+
580
+ ```bash
581
+ npx @payloadcms/codemod --transform migrate-field-component-types
582
+ ```
583
+
584
+ ### Diff component props `comparisonValue` and `versionValue` renamed to `valueFrom` and `valueTo`
585
+
586
+ 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`.
587
+
588
+ | Old prop | New prop | Description |
589
+ | ----------------- | ----------- | ------------------------------------------------ |
590
+ | `comparisonValue` | `valueFrom` | Field value from the version being compared from |
591
+ | `versionValue` | `valueTo` | Field value from the version being compared to |
592
+
593
+ ```diff
594
+ import type { TextFieldDiffClientProps } from 'payload'
595
+ import type React from 'react'
596
+
597
+ export const MyTextDiff: React.FC<TextFieldDiffClientProps> = ({
598
+ - comparisonValue,
599
+ - versionValue,
600
+ + valueFrom,
601
+ + valueTo,
602
+ }) => {
603
+ // ...
604
+ }
605
+ ```
606
+
607
+ #### Codemod
608
+
609
+ To migrate automatically, there's a codemod for this change available by running:
610
+
611
+ ```bash
612
+ npx @payloadcms/codemod --transform rename-diff-value-props
613
+ ```
614
+
615
+ 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.
616
+
617
+ ### Default value functions can now return promises ([#17522](https://github.com/payloadcms/payload/pull/17522))
618
+
619
+ 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`.
620
+
621
+ **What changes:**
622
+
623
+ - 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.
624
+ - 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.
625
+
626
+ ```diff
627
+ import type { DefaultValue } from 'payload'
628
+
629
+ - const resolveDefault = (fn: DefaultValue, args: Args) => {
630
+ - const value = fn(args)
631
+ - return value.toString()
632
+ - }
633
+ + const resolveDefault = async (fn: DefaultValue, args: Args) => {
634
+ + const value = await fn(args)
635
+ + return value.toString()
636
+ + }
637
+ ```
638
+
639
+ If you only use `DefaultValue` to type a `defaultValue` property passed into a Collection, Global, or Field config, no changes are needed.
640
+
641
+ ### Sanitized collection and auth config types now match runtime defaults
642
+
643
+ `SanitizedCollectionConfig` and its auth type no longer use `DeepRequired`. This reduces TypeScript checker work and makes optionality match runtime defaults.
644
+
645
+ Properties Payload does not default, such as `auth.cookies.domain`, `auth.useAPIKey`, and `auth.depth`, now require undefined handling:
646
+
647
+ ```ts
648
+ const useAPIKey = collection.auth.useAPIKey ?? false
649
+ ```
650
+
651
+ 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.
652
+
653
+ ### Sanitized root config types now match runtime defaults
654
+
655
+ `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`.
656
+
657
+ Code that consumes these properties from `payload.config` may now need to handle `undefined`:
658
+
659
+ ```ts
660
+ const autoLogin = payload.config.admin.autoLogin
661
+ const presets = payload.config.queryPresets?.constraints
662
+ ```
663
+
664
+ 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.
665
+
666
+ ### Sanitized global config types now match runtime defaults
667
+
668
+ `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`.
669
+
670
+ Code that consumes these properties may now need to handle `undefined`:
671
+
672
+ ```ts
673
+ const views = global.admin.components?.views
674
+ const interfaceName = global.typescript?.interface
675
+ ```
676
+
677
+ 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.
678
+
679
+ ### User types have been consolidated
680
+
681
+ 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.
682
+
683
+ 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`.
684
+
685
+ There are now two types:
686
+
687
+ - **`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.
688
+ - **`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.
689
+
690
+ `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.
691
+
692
+ `_sid` is flow-dependent: request-authenticated users can carry it, while standard login, `me`, and refresh responses omit it.
693
+
694
+ The `collection` property remains available on authenticated users and in client auth responses. Removing `ClientUser` does not change runtime serialization.
695
+
696
+ **What changes:**
697
+
698
+ - `TypedUser` is removed — replace it with `User`.
699
+ - `UntypedUser` is removed — use `User` for a user document or `AuthenticatedUser` for the signed-in user.
700
+ - `ClientUser` is removed — replace it with `AuthenticatedUser` for `useAuth().user`, `me` responses, and other client auth APIs.
701
+ - `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.
702
+ - `req.user` and `payload.auth().user` are now typed as `AuthenticatedUser`, so `_strategy` and `_sid` type-check.
703
+ - The deprecated top-level `strategy` field is removed from `me` and `refresh` responses (REST, GraphQL, and the SDK). Read `user._strategy` instead.
704
+ - `@payloadcms/plugin-ecommerce`'s `ClientUserWithCart` is removed — replace it with `UserWithCart`.
705
+ - `MeOperationResult.user` and `refreshCookieAsync()` can be `null`, matching their existing runtime behavior. `refreshCookieAsync()` also preserves the custom user type passed to `useAuth<T>()`.
706
+ - Collection `admin.hidden` callbacks now receive `PayloadRequest['user']`, including `null` when there is no authenticated user.
707
+ - 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`.
708
+ - `UserSession.createdAt` is now optional and nullable (`createdAt?: Date | null | string`) to match the generated session shape. Callers must handle a missing value.
709
+
710
+ `AuthenticatedUser` is assignable to `User`, so passing `req.user` to Local API operations continues to work. Passing an arbitrary object now type-errors.
711
+
712
+ ```diff
713
+ - import type { ClientUser, TypedUser, UntypedUser } from 'payload'
714
+ + import type { AuthenticatedUser, User } from 'payload'
715
+
716
+ - const user: TypedUser = req.user
717
+ + const user: AuthenticatedUser = req.user
718
+
719
+ - type CurrentUser = ClientUser
720
+ + type CurrentUser = AuthenticatedUser
721
+ ```
722
+
723
+ 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()`.
724
+
725
+ **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):
726
+
727
+ ```ts
728
+ import type { AuthenticatedUser } from 'payload'
729
+
730
+ // Your plugin adds a `cart` join field (or any custom field) to the user collection.
731
+ type UserWithCart = AuthenticatedUser & {
732
+ cart?: { docs?: (Cart | string)[] } | null
733
+ }
734
+
735
+ const user = req.user as null | UserWithCart
736
+ user?.cart?.docs // now typed
737
+ ```
738
+
739
+ If the field name is dynamic (configurable at runtime), index through `Record<string, unknown>` instead:
740
+
741
+ ```ts
742
+ const value = (user as Record<string, unknown>)[fieldNameFromConfig]
743
+ ```
744
+
745
+ ### Plugins now infer generated types automatically
746
+
747
+ 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`.
748
+
749
+ Drop the type argument — your callbacks are typed correctly without it.
750
+
751
+ ```diff
752
+ - multiTenantPlugin<{ user: MyUser }>({ ... })
753
+ + multiTenantPlugin({ ... })
754
+ ```
755
+
756
+ ### Plugin definitions now require a slug and named options
757
+
758
+ The experimental `definePlugin` helper now requires a `slug` and passes plugin options through a named `options` argument:
759
+
760
+ - **`slug` is now required.** It registers the plugin in the `plugins` map for cross-plugin discovery. Add one to every `definePlugin` call.
761
+ - **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.
762
+
763
+ ```diff
764
+ export const myPlugin = definePlugin<MyPluginOptions>({
765
+ - // slug was optional
766
+ + slug: 'my-plugin',
767
+ - plugin: ({ config, plugins, collections }) => ({ ...config }),
768
+ + plugin: ({ config, options }) => ({ ...config }),
769
+ })
770
+ ```
771
+
772
+ ### Admin routing is now framework-agnostic
773
+
774
+ `@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 such as TanStack Start without modification.
775
+
776
+ **Hooks** — import from `@payloadcms/ui` instead of `next/navigation`:
777
+
778
+ | Old import | New import |
779
+ | --------------------------------------------------- | -------------------------------------------------- |
780
+ | `import { useRouter } from 'next/navigation'` | `import { useRouter } from '@payloadcms/ui'` |
781
+ | `import { usePathname } from 'next/navigation'` | `import { usePathname } from '@payloadcms/ui'` |
782
+ | `import { useSearchParams } from 'next/navigation'` | `import { useSearchParams } from '@payloadcms/ui'` |
783
+ | `import { useParams } from 'next/navigation'` | `import { useParams } from '@payloadcms/ui'` |
784
+
785
+ **`Link` component** — use `PayloadLink` from `@payloadcms/ui`:
786
+
787
+ ```diff
788
+ - import Link from 'next/link'
789
+ + import { PayloadLink as Link } from '@payloadcms/ui'
790
+ ```
791
+
792
+ **Types** — `LinkProps` from `next/link` is replaced by `LinkAdapterProps` from `payload`:
793
+
794
+ ```diff
795
+ - import type { LinkProps } from 'next/link'
796
+ + import type { LinkAdapterProps } from 'payload'
797
+ ```
798
+
799
+ `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`.
800
+
801
+ **`RouterAdapterRouter` shape** — the object returned by `useRouter()` now has a stable, framework-agnostic surface:
802
+
803
+ ```ts
804
+ type RouterAdapterRouter = {
805
+ back: () => void
806
+ push: (path: string, options?: { scroll?: boolean }) => void
807
+ refresh: () => void
808
+ replace: (path: string, options?: { scroll?: boolean }) => void
809
+ }
810
+ ```
811
+
812
+ If you were relying on Next-specific extras like `prefetch()` on the router instance, switch to the `Link` component's `prefetch` prop instead.
813
+
814
+ **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:
815
+
816
+ ```diff
817
+ + import { NextRouterAdapter } from '@payloadcms/next/elements/RouterAdapter'
818
+
819
+ <RootProvider
820
+ + RouterAdapter={NextRouterAdapter}
821
+ config={clientConfig}
822
+ /* ... */
823
+ />
824
+ ```
825
+
826
+ ### Next.js subpath exports have moved to the UI package
827
+
828
+ 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.
829
+
830
+ | Symbol | Old source | New source |
831
+ | -------------------------- | ---------------------------- | -------------------- |
832
+ | `DefaultTemplate` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` |
833
+ | `MinimalTemplate` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` |
834
+ | `CollectionCards` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` |
835
+ | `DefaultNav` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` |
836
+ | `DocumentHeader` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` |
837
+ | `HierarchyTypeFieldServer` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` |
838
+ | `Logo` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` |
839
+ | `DefaultNavClient` | `@payloadcms/next/client` | `@payloadcms/ui` |
840
+ | `HierarchyTypeField` | `@payloadcms/next/client` | `@payloadcms/ui` |
841
+ | `NavSidebarToggle` | `@payloadcms/next/client` | `@payloadcms/ui` |
842
+ | `NavWrapper` | `@payloadcms/next/client` | `@payloadcms/ui` |
843
+ | `QueryPresetsAccessCell` | `@payloadcms/next/client` | `@payloadcms/ui` |
844
+ | `QueryPresetsColumnField` | `@payloadcms/next/client` | `@payloadcms/ui` |
845
+ | `QueryPresetsColumnsCell` | `@payloadcms/next/client` | `@payloadcms/ui` |
846
+ | `QueryPresetsGroupByCell` | `@payloadcms/next/client` | `@payloadcms/ui` |
847
+ | `QueryPresetsGroupByField` | `@payloadcms/next/client` | `@payloadcms/ui` |
848
+ | `QueryPresetsWhereCell` | `@payloadcms/next/client` | `@payloadcms/ui` |
849
+ | `QueryPresetsWhereField` | `@payloadcms/next/client` | `@payloadcms/ui` |
850
+ | `SlugField` | `@payloadcms/next/client` | `@payloadcms/ui` |
851
+
852
+ ```diff
853
+ - import { DefaultTemplate, MinimalTemplate } from '@payloadcms/next/templates'
854
+ - import { DocumentHeader, Logo } from '@payloadcms/next/rsc'
855
+ - import { HierarchyTypeField, SlugField } from '@payloadcms/next/client'
856
+ + import { DefaultTemplate, DocumentHeader, Logo, MinimalTemplate } from '@payloadcms/ui/rsc'
857
+ + import { HierarchyTypeField, SlugField } from '@payloadcms/ui'
858
+ ```
859
+
860
+ 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`.
861
+
862
+ Run `npx @payloadcms/codemod --transform migrate-next-subpath-exports` to migrate automatically. It rewrites both `import` declarations and string component paths.
863
+
864
+ ### Document title state has moved to its own hook
865
+
866
+ 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.
867
+
868
+ ```diff
869
+ 'use client'
870
+ - import { useDocumentInfo } from '@payloadcms/ui'
871
+ + import { useDocumentTitle } from '@payloadcms/ui'
872
+
873
+ const Heading: React.FC = () => {
874
+ - const { title, setDocumentTitle } = useDocumentInfo()
875
+ + const { title, setDocumentTitle } = useDocumentTitle()
876
+ return <h2>{title}</h2>
877
+ }
878
+ ```
879
+
880
+ 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.
881
+
882
+ 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 }`).
883
+
884
+ ### Locale hooks can now return null
885
+
886
+ 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.
887
+
888
+ Custom Client Components that read locale properties directly must handle the nullable value:
889
+
890
+ ```diff
891
+ 'use client'
892
+ import { useLocale } from '@payloadcms/ui'
893
+
894
+ const Greeting: React.FC = () => {
895
+ - const { code } = useLocale()
896
+ + const locale = useLocale()
897
+ +
898
+ + if (!locale) {
899
+ + return null
900
+ + }
901
+ +
902
+ + const { code } = locale
903
+
904
+ return <span>{code}</span>
905
+ }
906
+ ```
907
+
908
+ When a missing locale is already valid for the receiving API, optional chaining is sufficient:
909
+
910
+ ```ts
911
+ const locale = useLocale()?.code
912
+ ```
913
+
914
+ ### `readOnly` removed from the `useField` return type
915
+
916
+ 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.
917
+
918
+ Read `readOnly` from your field component's props instead:
919
+
920
+ ```diff
921
+ 'use client'
922
+ import type { TextFieldClientComponent } from 'payload'
923
+ import { useField } from '@payloadcms/ui'
924
+
925
+ -export const MyField: TextFieldClientComponent = ({ path }) => {
926
+ - const { readOnly, value } = useField({ path })
927
+ +export const MyField: TextFieldClientComponent = ({ path, readOnly }) => {
928
+ + const { value } = useField({ path })
929
+ ```
930
+
931
+ ### Deprecated `TableColumnsProvider` props removed
932
+
933
+ The following deprecated props have been removed from `TableColumnsProvider` and its `TableColumnsProviderProps` type:
934
+
935
+ - `docs`
936
+ - `enableRowSelections`
937
+ - `listPreferences`
938
+ - `preferenceKey`
939
+ - `renderRowTypes`
940
+ - `setTable`
941
+ - `sortColumnProps`
942
+ - `tableAppearance`
943
+
944
+ `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`.
945
+
946
+ ```diff
947
+ <TableColumnsProvider
948
+ collectionSlug={collectionSlug}
949
+ columnState={columnState}
950
+ - enableRowSelections
951
+ - preferenceKey={preferenceKey}
952
+ - tableAppearance="condensed"
953
+ >
954
+ {children}
955
+ </TableColumnsProvider>
956
+ ```
957
+
958
+ ### Rich text adapters no longer expose translations
959
+
960
+ 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.
961
+
962
+ 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:
963
+
964
+ ```diff
965
+ // my-richtext-adapter/index.ts
966
+ export const myRichTextAdapter = (): RichTextAdapterProvider => {
967
+ return ({ config }) => {
968
+ const myTranslations = { en: { myKey: 'My translation' } }
969
+
970
+ - return {
971
+ - // ... other adapter properties
972
+ - i18n: myTranslations,
973
+ - }
974
+ + config.i18n.translations = deepMergeSimple(config.i18n.translations, myTranslations)
975
+ + return {
976
+ + // ... other adapter properties
977
+ + }
978
+ }
979
+ }
980
+ ```
981
+
982
+ ### Config and rich-text sanitization are now synchronous
983
+
984
+ 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:
985
+
986
+ ```diff
987
+ - const sanitizedConfig = await sanitizeConfig(config)
988
+ + const sanitizedConfig = sanitizeConfig(config)
989
+ ```
990
+
991
+ 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`.
992
+
993
+ The optional `richTextSanitizationPromises` argument on the field sanitizers has been renamed to `richTextSanitizers`. Its callbacks are now synchronous:
994
+
995
+ ```diff
996
+ - richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>>
997
+ + richTextSanitizers: Array<(config: SanitizedConfig) => void>
998
+ ```
999
+
1000
+ Custom rich-text adapter providers and Lexical server feature callbacks must also return their value synchronously:
1001
+
1002
+ ```diff
1003
+ export const MyFeature = createServerFeature({
1004
+ key: 'my-feature',
1005
+ - feature: async () => ({
1006
+ + feature: () => ({
1007
+ ClientFeature: '@my-package/client#MyFeature',
1008
+ sanitizedServerFeatureProps: null,
1009
+ }),
1010
+ })
1011
+ ```
1012
+
1013
+ 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.
1014
+
1015
+ ### Transaction requests now use a single identifier property
1016
+
1017
+ 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.
1018
+
1019
+ ### `headers` and `languageCode` removed from `AdminContext`
1020
+
1021
+ The `headers` and `languageCode` properties have been removed from `AdminContext`, the object returned by `initAdminContext`. Both duplicated values that are already available on `req`:
1022
+
1023
+ ```diff
1024
+ const context = await initAdminContext(args)
1025
+
1026
+ -const userAgent = context.headers.get('user-agent')
1027
+ +const userAgent = context.req.headers.get('user-agent')
1028
+
1029
+ -const languageCode = context.languageCode
1030
+ +const languageCode = context.req.i18n.language
1031
+ ```
1032
+
1033
+ `req.i18n.language` is typed as `string`. Cast it to `AcceptedLanguages` if you need the narrower type.
1034
+
1035
+ `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`.
1036
+
1037
+ #### Codemod
1038
+
1039
+ To migrate automatically, run:
1040
+
1041
+ ```bash
1042
+ npx @payloadcms/codemod --transform migrate-admin-context-properties
1043
+ ```
1044
+
1045
+ 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.
1046
+
1047
+ ### `DiffMethod` type removed
1048
+
1049
+ 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`.
1050
+
1051
+ ### `diffMethod` prop removed from Diff components
1052
+
1053
+ 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.
1054
+
1055
+ Remove `diffMethod` from your props. If you passed it on to a diffing utility, pass `'diffWordsWithSpace'` directly instead:
1056
+
1057
+ ```diff
1058
+ export const MyTextDiff: React.FC<TextFieldDiffClientProps> = ({
1059
+ - diffMethod,
1060
+ valueFrom,
1061
+ valueTo,
1062
+ }) => {
1063
+ ```
1064
+
1065
+ ### Collection after-operation hooks no longer report read operations
1066
+
1067
+ 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.
1068
+
1069
+ If a custom `afterOperation` hook branches on `operation === 'read'`, split it to handle `'find'` and `'findByID'`:
1070
+
1071
+ ```diff
1072
+ const afterOperation: CollectionAfterOperationHook = ({ operation, result }) => {
1073
+ - if (operation === 'read') {
1074
+ + if (operation === 'find' || operation === 'findByID') {
1075
+ // ...
1076
+ }
1077
+
1078
+ return result
1079
+ }
1080
+ ```
1081
+
1082
+ `'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.
1083
+
1084
+ 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.
1085
+
1086
+ The `operation: 'read'` value on the `beforeOperation` hook is unchanged.
1087
+
1088
+ ### Static configuration defaults have been removed ([#17103](https://github.com/payloadcms/payload/pull/17103))
1089
+
1090
+ 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.
1091
+
1092
+ 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.
1093
+ Alternatively, you can call `addDefaultsToConfig` to get the unsanitized config but populated with the default properties.
1094
+
1095
+ ### Import and export field callbacks have moved to hooks
1096
+
1097
+ 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.
1098
+
1099
+ The argument shapes differ slightly from the old API:
1100
+
1101
+ | Old (`toCSV`) | New (`hooks.beforeExport`) |
1102
+ | ------------- | ----------------------------------- |
1103
+ | `row` | `siblingData` |
1104
+ | `doc` | `data` |
1105
+ | `data` | _(removed — was alias for `row`)_ |
1106
+ | — | `format` (new: `'csv'` \| `'json'`) |
1107
+
1108
+ The `fromCSV` → `hooks.beforeImport` change is non-breaking for the `data` parameter — both receive the full flat row.
1109
+
1110
+ ```diff
1111
+ {
1112
+ name: 'author',
1113
+ type: 'relationship',
1114
+ relationTo: 'users',
1115
+ custom: {
1116
+ 'plugin-import-export': {
1117
+ - toCSV: ({ value, columnName, row, doc }) => {
1118
+ - row[`${columnName}_id`] = (value as any).id
1119
+ - return doc.title
1120
+ - },
1121
+ - fromCSV: ({ value, data }) => data[`${value}_key`],
1122
+ + hooks: {
1123
+ + beforeExport: ({ value, columnName, siblingData, data }) => {
1124
+ + siblingData[`${columnName}_id`] = (value as any).id
1125
+ + return data.title
1126
+ + },
1127
+ + beforeImport: ({ value, data }) => data[`${value}_key`],
1128
+ + },
1129
+ },
1130
+ },
1131
+ }
1132
+ ```
1133
+
1134
+ 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`.
1135
+
1136
+ ### Database adapter types now use the main package exports
1137
+
1138
+ 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.
1139
+
1140
+ | Old import | New import |
1141
+ | -------------------------------------- | -------------------------------- |
1142
+ | `@payloadcms/drizzle/types` | `@payloadcms/drizzle` |
1143
+ | `@payloadcms/db-postgres/types` | `@payloadcms/db-postgres` |
1144
+ | `@payloadcms/db-sqlite/types` | `@payloadcms/db-sqlite` |
1145
+ | `@payloadcms/db-vercel-postgres/types` | `@payloadcms/db-vercel-postgres` |
1146
+ | `@payloadcms/db-d1-sqlite/types` | `@payloadcms/db-d1-sqlite` |
1147
+
1148
+ ```diff
1149
+ - import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
1150
+ - import type { PostgresAdapter } from '@payloadcms/db-postgres/types'
1151
+ - import type { SQLiteAdapter } from '@payloadcms/db-sqlite/types'
1152
+ - import type { VercelPostgresAdapter } from '@payloadcms/db-vercel-postgres/types'
1153
+ - import type { SQLiteD1Adapter } from '@payloadcms/db-d1-sqlite/types'
1154
+ + import type { DrizzleAdapter } from '@payloadcms/drizzle'
1155
+ + import type { PostgresAdapter } from '@payloadcms/db-postgres'
1156
+ + import type { SQLiteAdapter } from '@payloadcms/db-sqlite'
1157
+ + import type { VercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
1158
+ + import type { SQLiteD1Adapter } from '@payloadcms/db-d1-sqlite'
1159
+ ```
1160
+
1161
+ If you use a `declare module` augmentation to extend `GeneratedDatabaseSchema` (typically found in generated schema files), update the module path as well:
1162
+
1163
+ ```diff
1164
+ - declare module '@payloadcms/db-postgres/types' {
1165
+ + declare module '@payloadcms/db-postgres' {
1166
+ export interface GeneratedDatabaseSchema {
1167
+ schema: DatabaseSchema
1168
+ }
1169
+ }
1170
+ ```
1171
+
1172
+ Run `npx @payloadcms/codemod --transform migrate-db-types-subpath` to migrate automatically. The codemod handles import declarations, re-export declarations, and `declare module` augmentations.
1173
+
1174
+ ### `findMigrationDir` removed from `@payloadcms/drizzle`
1175
+
1176
+ The deprecated `findMigrationDir` re-export has been removed from `@payloadcms/drizzle`. Import it from `payload` instead:
1177
+
1178
+ ```diff
1179
+ - import { findMigrationDir } from '@payloadcms/drizzle'
1180
+ + import { findMigrationDir } from 'payload'
1181
+ ```
1182
+
1183
+ ### Minimum supported Node.js version is now 24.15.0 ([#16540](https://github.com/payloadcms/payload/pull/16540))
1184
+
1185
+ 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.
1186
+
1187
+ ### Minimum supported Next.js version is now 16.4.0 ([#16537](https://github.com/payloadcms/payload/pull/16537))
1188
+
1189
+ Older versions of Next.js are no longer supported. Update Next.js to **16.4.0** or newer.
1190
+
1191
+ ### Minimum supported TypeScript version is now 6.0.3 ([#16692](https://github.com/payloadcms/payload/pull/16692))
1192
+
1193
+ 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.
1194
+
1195
+ ### The Slate rich text editor has been removed ([#16359](https://github.com/payloadcms/payload/pull/16359))
1196
+
1197
+ 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.
1198
+
1199
+ The following are removed:
1200
+
1201
+ - The `@payloadcms/richtext-slate` package
1202
+ - The Slate → Lexical migration utilities in `@payloadcms/richtext-lexical/migrate`
1203
+ - The `payload-plugin-lexical` migration utilities
1204
+ - All Slate references in the documentation
1205
+
1206
+ 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.
1207
+
1208
+ 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](https://payloadcms.com/docs/rich-text/migration) first.
1209
+
1210
+ ### Legacy Lexical HTML converters have been removed ([#16548](https://github.com/payloadcms/payload/pull/16548))
1211
+
1212
+ 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.
1213
+
1214
+ ```diff
1215
+ - import { HTMLConverterFeature, lexicalHTML, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
1216
+ + import { lexicalHTMLField, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
1217
+
1218
+ editor: lexicalEditor({
1219
+ features: ({ defaultFeatures }) => [
1220
+ ...defaultFeatures,
1221
+ - HTMLConverterFeature({}),
1222
+ ],
1223
+ }),
1224
+
1225
+ fields: [
1226
+ { name: 'content', type: 'richText' },
1227
+ - lexicalHTML('content', { name: 'content_html' }),
1228
+ + lexicalHTMLField({ htmlFieldName: 'content_html', lexicalFieldName: 'content' }),
1229
+ ]
1230
+ ```
1231
+
1232
+ Custom nodes that define `converters.html` should export their converters instead.
1233
+
1234
+ ### Lexical now uses its built-in HTML element check ([#17104](https://github.com/payloadcms/payload/pull/17104))
1235
+
1236
+ 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.
1237
+
1238
+ ```diff
1239
+ - import { isHTMLElement } from '@payloadcms/richtext-lexical/client'
1240
+ + import { isHTMLElement } from 'lexical'
1241
+ ```
1242
+
1243
+ `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.
1244
+
1245
+ ### Lexical now uses the upstream Markdown package
1246
+
1247
+ `@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`.
1248
+
1249
+ 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.
1250
+
1251
+ **`$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):
1252
+
1253
+ ```diff
1254
+ - $convertFromMarkdownString(markdown, transformers)
1255
+ + $convertFromMarkdownString(markdown, transformers, undefined, false, true)
1256
+ ```
1257
+
1258
+ **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:
1259
+
1260
+ ```diff
1261
+ - import { $convertFromMarkdownString, TRANSFORMERS } from '@payloadcms/richtext-lexical/lexical/markdown'
1262
+ - $convertFromMarkdownString(markdown, TRANSFORMERS)
1263
+ + import { convertMarkdownToLexical } from '@payloadcms/richtext-lexical'
1264
+ + convertMarkdownToLexical({ editorConfig, markdown })
1265
+ ```
1266
+
1267
+ 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.
1268
+
1269
+ **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.
1270
+
1271
+ If you import lexical APIs directly through `@payloadcms/richtext-lexical/lexical/*`, also review the [lexical breaking changes](https://github.com/facebook/lexical/releases) between `0.41.0` and `0.52.0`. None of them require action for typical Payload usage.
1272
+
1273
+ 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:
1274
+
1275
+ - Lexical packages are ESM only. Use ESM imports; CommonJS `require()` needs Node.js 20.19 or later.
1276
+ - `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.
1277
+ - 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.
1278
+ - `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[]`.
1279
+ - `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.
1280
+ - `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.
1281
+
1282
+ ### Lexical table support is no longer experimental
1283
+
1284
+ `EXPERIMENTAL_TableFeature` has been renamed to `TableFeature` now that the table feature is no longer experimental.
1285
+
1286
+ ```diff
1287
+ - import { EXPERIMENTAL_TableFeature } from '@payloadcms/richtext-lexical'
1288
+ + import { TableFeature } from '@payloadcms/richtext-lexical'
1289
+
1290
+ editor: lexicalEditor({
1291
+ - features: ({ rootFeatures }) => [...rootFeatures, EXPERIMENTAL_TableFeature()],
1292
+ + features: ({ rootFeatures }) => [...rootFeatures, TableFeature()],
1293
+ })
1294
+ ```
1295
+
1296
+ Stored content is unaffected.
1297
+
1298
+ ### Lexical: `LexicalEditorProps` renamed to `LexicalEditorArgs`
1299
+
1300
+ 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.
1301
+
1302
+ ```diff
1303
+ - import type { LexicalEditorProps } from '@payloadcms/richtext-lexical'
1304
+ + import type { LexicalEditorArgs } from '@payloadcms/richtext-lexical'
1305
+ ```
1306
+
1307
+ ### Lexical: `UploadDataImproved` replaced by `UploadData` ([#18606](https://github.com/payloadcms/payload/pull/18606))
1308
+
1309
+ 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`:
1310
+
1311
+ ```ts
1312
+ import type { UploadData } from '@payloadcms/richtext-lexical'
1313
+ ```
1314
+
1315
+ ### Lexical: the `version` of nodes is ignored
1316
+
1317
+ Lexical deprecated the `version` property of serialized nodes, and now ignores it when it loads a document. Payload no longer reads it either.
1318
+
1319
+ **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:
1320
+
1321
+ - Block nodes whose fields were wrapped in an extra `data` object (`fields.data`)
1322
+ - Relationship and upload nodes that stored the whole related document in `value`, instead of its ID
1323
+
1324
+ 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:
1325
+
1326
+ ```ts
1327
+ import { upgradeLexicalData } from '@payloadcms/richtext-lexical'
1328
+
1329
+ await upgradeLexicalData({ payload })
1330
+ ```
1331
+
1332
+ **`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.
1333
+
1334
+ **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.
1335
+
1336
+ ### Field schema configuration now reflects JSON Schema
1337
+
1338
+ `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.
1339
+
1340
+ ```diff
1341
+ {
1342
+ name: 'tags',
1343
+ type: 'json',
1344
+ - typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
1345
+ + jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
1346
+ }
1347
+ ```
1348
+
1349
+ ### Rich text output schemas have been renamed
1350
+
1351
+ 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`.
1352
+
1353
+ ```diff
1354
+ myAdapter: RichTextAdapter = {
1355
+ - outputSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired }) => {
1356
+ + jsonSchema: ({ collectionIDFieldTypes, config, field, i18n, interfaceNameDefinitions, isRequired, typeStringDefinitions }) => {
1357
+ // return JSONSchema4
1358
+ },
1359
+ }
1360
+ ```
1361
+
1362
+ `@payloadcms/richtext-lexical` is updated. Only third-party adapters need attention.
1363
+
1364
+ ### Schema generation now returns definitions alongside JSON Schema
1365
+
1366
+ `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.
1367
+
1368
+ ```diff
1369
+ - const schema = configToJSONSchema(sanitizedConfig, 'text')
1370
+ + const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')
1371
+ ```
1372
+
1373
+ If you run your own type generation, append `[...typeStringDefinitions].join('\n\n')` to the compiled output. Otherwise ignore it.
1374
+
1375
+ ### Field schema generation now uses object arguments
1376
+
1377
+ `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`.
1378
+
1379
+ ```diff
1380
+ - fieldsToJSONSchema(
1381
+ - collectionIDFieldTypes,
1382
+ - fields,
1383
+ - interfaceNameDefinitions,
1384
+ - config,
1385
+ - i18n,
1386
+ - { forceInlineBlocks: true },
1387
+ - )
1388
+ + fieldsToJSONSchema({
1389
+ + collectionIDFieldTypes,
1390
+ + config,
1391
+ + fields,
1392
+ + forceInlineBlocks: true,
1393
+ + i18n,
1394
+ + interfaceNameDefinitions,
1395
+ + typeStringDefinitions,
1396
+ + })
1397
+ ```
1398
+
1399
+ ### Entity schema generation now requires type definitions
1400
+
1401
+ `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.
1402
+
1403
+ ```diff
1404
+ entityToJSONSchema(
1405
+ config,
1406
+ entity,
1407
+ interfaceNameDefinitions,
1408
+ defaultIDType,
1409
+ + typeStringDefinitions,
1410
+ collectionIDFieldTypes,
1411
+ i18n,
1412
+ - { forceInlineBlocks: true },
1413
+ + true,
1414
+ )
1415
+ ```
1416
+
1417
+ ### Lexical nodes now provide their own JSON Schemas
1418
+
1419
+ `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.
1420
+
1421
+ If you wrote a custom feature that overrode `modifyJSONSchema`, move per-node generation into `createNode({ jsonSchema: ... })`:
1422
+
1423
+ ```diff
1424
+ export const MyFeature = createServerFeature({
1425
+ feature: () => ({
1426
+ - generatedTypes: {
1427
+ - modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => {
1428
+ - // ... mutate currentSchema or interfaceNameDefinitions ...
1429
+ - return currentSchema
1430
+ - },
1431
+ - },
1432
+ nodes: [
1433
+ createNode({
1434
+ node: MyNode,
1435
+ + jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => {
1436
+ + typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { type: 'my'; /* ... */ }`)
1437
+ + return elementNodeSchema({
1438
+ + nodeType: 'my',
1439
+ + tsType: `SerializedMyNode<${nodeUnionName}>`,
1440
+ + })
1441
+ + },
1442
+ }),
1443
+ ],
1444
+ }),
1445
+ })
1446
+ ```
1447
+
1448
+ 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.
1449
+
1450
+ ### Lexical: duplicate node registration throws
1451
+
1452
+ `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.
1453
+
1454
+ 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:
1455
+
1456
+ ```diff
1457
+ createNode({
1458
+ - node: MyExtendedHeadingNode,
1459
+ + node: { replace: HeadingNode, with: (node) => new MyExtendedHeadingNode(node.__tag) },
1460
+ })
1461
+ ```
1462
+
1463
+ ### Every block now auto-generates a top-level TypeScript interface
1464
+
1465
+ 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.
1466
+
1467
+ 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.
1468
+
1469
+ 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.
1470
+
1471
+ 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](/docs/typescript/generating-types#block-interface-name-collisions).
1472
+
1473
+ ### Block references now share the blocks array
1474
+
1475
+ 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.
1476
+
1477
+ Run `npx @payloadcms/codemod --transform migrate-block-references-to-blocks` to migrate automatically.
1478
+
1479
+ ```diff
1480
+ export const Pages: CollectionConfig = {
1481
+ slug: 'pages',
1482
+ fields: [
1483
+ {
1484
+ name: 'layout',
1485
+ type: 'blocks',
1486
+ - blockReferences: ['hero', 'content'],
1487
+ - blocks: [],
1488
+ + blocks: ['hero', 'content'],
1489
+ },
1490
+ ],
1491
+ }
1492
+ ```
1493
+
1494
+ Inline blocks continue to work as before:
1495
+
1496
+ ```ts
1497
+ {
1498
+ name: 'layout',
1499
+ type: 'blocks',
1500
+ blocks: [HeroBlock, ContentBlock],
1501
+ }
1502
+ ```
1503
+
1504
+ ### Generated Lexical types are now strict
1505
+
1506
+ `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`.
1507
+
1508
+ 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.
1509
+
1510
+ Code that imported field types from `payload-types.ts` and cast through `unknown` may now type-check tighter and surface real bugs.
1511
+
1512
+ 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.
1513
+
1514
+ 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).
1515
+
1516
+ 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):
1517
+
1518
+ ```diff
1519
+ import type {
1520
+ SerializedParagraphNode,
1521
+ SerializedTextNode,
1522
+ TypedEditorState,
1523
+ } from '@payloadcms/richtext-lexical'
1524
+
1525
+ - type MyNodes = SerializedParagraphNode | SerializedTextNode
1526
+ + type MyNodes = SerializedParagraphNode<MyNodes> | SerializedTextNode
1527
+
1528
+ function renderRichText(state: TypedEditorState<MyNodes>) {
1529
+ // ...
1530
+ }
1531
+ ```
1532
+
1533
+ `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.
1534
+
1535
+ ### Generated JSON Schema now uses modern definitions
1536
+
1537
+ 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.
1538
+
1539
+ **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.
1540
+
1541
+ Update any code that adds to the schema:
1542
+
1543
+ ```diff
1544
+ // payload.config.ts
1545
+ {
1546
+ typescript: {
1547
+ schema: [
1548
+ ({ jsonSchema }) => {
1549
+ - jsonSchema.definitions.MyType = { type: 'object', properties: { /* ... */ } }
1550
+ + jsonSchema.$defs.MyType = { type: 'object', properties: { /* ... */ } }
1551
+ return jsonSchema
1552
+ },
1553
+ ],
1554
+ },
1555
+ }
1556
+ ```
1557
+
1558
+ And update any `$ref` pointers you wrote by hand to point at `$defs`:
1559
+
1560
+ ```diff
1561
+ - { $ref: '#/definitions/posts' }
1562
+ + { $ref: '#/$defs/posts' }
1563
+ ```
1564
+
1565
+ Do not keep your additions on `definitions` while new ones land on `$defs`.
1566
+
1567
+ ### Jobs: access is enforced by default
1568
+
1569
+ 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`.
1570
+
1571
+ 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.
1572
+
1573
+ 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:
1574
+
1575
+ ```ts
1576
+ jobs: {
1577
+ jobsCollectionOverrides: ({ defaultJobsCollection }) => ({
1578
+ ...defaultJobsCollection,
1579
+ access: {
1580
+ ...defaultJobsCollection.access,
1581
+ read: ({ req }) => Boolean(req.user?.roles?.includes('admin')),
1582
+ },
1583
+ admin: {
1584
+ ...defaultJobsCollection.admin,
1585
+ hidden: false,
1586
+ },
1587
+ }),
1588
+ }
1589
+ ```
1590
+
1591
+ Job documents can contain inputs, outputs, logs, and execution state, so avoid enabling write access unless your application specifically requires it.
1592
+
1593
+ ### Job queries and hooks now have fixed behavior ([#16414](https://github.com/payloadcms/payload/pull/16414))
1594
+
1595
+ The deprecated `jobs.depth` and `jobs.runHooks` config options have been removed. Job operations now always use direct database calls with depth `0`.
1596
+
1597
+ 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.
1598
+
1599
+ The `jobsCollectionOverrides` warning that detected extra hooks and prompted users to set `runHooks` has also been removed.
1600
+
1601
+ ### The legacy base job type has been removed
1602
+
1603
+ The deprecated `BaseJob` type is no longer exported. Use `Job` everywhere you previously used `BaseJob`.
1604
+
1605
+ ```diff
1606
+ - import type { BaseJob } from 'payload'
1607
+ + import type { Job } from 'payload'
1608
+
1609
+ - function processJob(job: BaseJob) {
1610
+ + function processJob(job: Job) {
1611
+ // ...
1612
+ }
1613
+ ```
1614
+
1615
+ `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.
1616
+
1617
+ The `false` fallback used by job and workflow generics has also been removed. Omit the generic when you want the default `object` input type.
1618
+
1619
+ ```diff
1620
+ - const processJob = (job: Job<false>) => {
1621
+ + const processJob = (job: Job) => {
1622
+ // ...
1623
+ }
1624
+
1625
+ - const workflow: WorkflowConfig<false> = {
1626
+ + const workflow: WorkflowConfig = {
1627
+ slug: 'my-workflow',
1628
+ // ...
1629
+ }
1630
+ ```
1631
+
1632
+ 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.
1633
+
1634
+ ### Jobs: stats and metadata are enabled by default
1635
+
1636
+ 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.
1637
+
1638
+ Existing projects that use sqlite/postgres and jobs may need a database migration.
1639
+
1640
+ ### Jobs: concurrency controls and parent task logging are always enabled
1641
+
1642
+ 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.
1643
+
1644
+ 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.
1645
+
1646
+ ### Jobs: task and workflow types have been renamed
1647
+
1648
+ The `TaskType` and `WorkflowTypes` exports have been renamed to `TaskSlug` and `WorkflowSlug`.
1649
+
1650
+ ```diff
1651
+ - import type { TaskType, WorkflowTypes } from 'payload'
1652
+ + import type { TaskSlug, WorkflowSlug } from 'payload'
1653
+ ```
1654
+
1655
+ The deprecated `RunningJob` and `RunningJobSimple` types have also been removed. Use `Job` instead.
1656
+
1657
+ 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.
1658
+
1659
+ ### Custom database adapters must support job claiming
1660
+
1661
+ 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.
1662
+
1663
+ 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.
1664
+
1665
+ ### Jobs: crashed workers are recovered with processing leases
1666
+
1667
+ 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.
1668
+
1669
+ 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`.
1670
+
1671
+ Existing SQL databases need a migration that replaces `processing` with the nullable, indexed `processingUntil` column. MongoDB does not require a migration.
1672
+
1673
+ ### Job task results no longer include state ([#16416](https://github.com/payloadcms/payload/pull/16416))
1674
+
1675
+ 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.
1676
+
1677
+ - `return { state: 'failed', errorMessage }` — throw an error instead.
1678
+ - `return { state: 'succeeded', output }` — return `{ output }` directly.
1679
+
1680
+ ```ts
1681
+ // Before
1682
+ const myTask: TaskHandler<'MyTask'> = async ({ input }) => {
1683
+ if (!input.id) {
1684
+ return { state: 'failed', errorMessage: 'Missing id' }
1685
+ }
1686
+ return { state: 'succeeded', output: { ok: true } }
1687
+ }
1688
+
1689
+ // After
1690
+ const myTask: TaskHandler<'MyTask'> = async ({ input }) => {
1691
+ if (!input.id) {
1692
+ throw new Error('Missing id')
1693
+ }
1694
+ return { output: { ok: true } }
1695
+ }
1696
+ ```
1697
+
1698
+ ### Cron expressions now require explicit ranges ([#16546](https://github.com/payloadcms/payload/pull/16546))
1699
+
1700
+ 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 `/`.
1701
+
1702
+ This affects job autorun crons (`jobs.autoRun[].cron`), scheduled task and workflow runs (`schedule.cron`), and the `payload` bin script `--cron` flag.
1703
+
1704
+ In strict mode (now the default), only these stepping forms are valid:
1705
+
1706
+ - `*/N` — wildcard with step
1707
+ - `start-end/N` — explicit range with step
1708
+
1709
+ The following forms now throw a `TypeError` at registration:
1710
+
1711
+ - `/N` — missing prefix
1712
+ - `M/N` — numeric prefix (single number before `/`)
1713
+
1714
+ Migrate any affected cron expressions:
1715
+
1716
+ ```diff
1717
+ # Missing prefix — the leading `*` is now required (both fire every 10 minutes)
1718
+ - cron: '/10 * * * *'
1719
+ + cron: '*/10 * * * *'
1720
+
1721
+ # Numeric prefix "every N starting from M" — make the range explicit (fires at 5, 20, 35, 50)
1722
+ - cron: '5/15 * * * *'
1723
+ + cron: '5-59/15 * * * *'
1724
+
1725
+ # Numeric prefix that produces a single value — drop the redundant step (only fires at minute 30)
1726
+ - cron: '30/30 * * * *'
1727
+ + cron: '30 * * * *'
1728
+ ```
1729
+
1730
+ ### Relationship and upload limits now use row-based options ([#16547](https://github.com/payloadcms/payload/pull/16547))
1731
+
1732
+ The deprecated `min` and `max` properties have been removed from `relationship` and `upload` fields with `hasMany: true`. Use `minRows` and `maxRows` instead.
1733
+
1734
+ ```diff
1735
+ {
1736
+ name: 'tags',
1737
+ type: 'relationship',
1738
+ relationTo: 'tags',
1739
+ hasMany: true,
1740
+ - min: 1,
1741
+ - max: 5,
1742
+ + minRows: 1,
1743
+ + maxRows: 5,
1744
+ }
1745
+ ```
1746
+
1747
+ The same change applies to `upload` fields with `hasMany: true`.
1748
+
1749
+ ### Nested localized fields no longer need a compatibility flag ([#16693](https://github.com/payloadcms/payload/pull/16693))
1750
+
1751
+ `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.
1752
+
1753
+ **Who is affected:**
1754
+
1755
+ - Users of `compatibility: { allowLocalizedWithinLocalized: true }`.
1756
+ - 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`.
1757
+
1758
+ **How to migrate:**
1759
+
1760
+ - 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.
1761
+ - Replace any direct `field.localized` reads in custom code with `fieldShouldBeLocalized({ field, parentIsLocalized })` from `payload/shared`.
1762
+
1763
+ ### Publishing now defaults to the active locale
1764
+
1765
+ 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.
1766
+
1767
+ **What changes:**
1768
+
1769
+ - `localization.defaultLocalePublishOption` no longer exists as a config property or type — remove it from your config.
1770
+ - If you had it set to `'active'`, there is no change in Admin UI behavior.
1771
+ - 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.
1772
+
1773
+ ```diff
1774
+ export default buildConfig({
1775
+ localization: {
1776
+ defaultLocale: 'en',
1777
+ - defaultLocalePublishOption: 'active',
1778
+ locales: ['en', 'es'],
1779
+ },
1780
+ })
1781
+ ```
1782
+
1783
+ 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.
1784
+
1785
+ ### Storage adapters have moved to top-level configuration ([#16596](https://github.com/payloadcms/payload/pull/16596))
1786
+
1787
+ 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`.
1788
+
1789
+ 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.
1790
+
1791
+ ```diff
1792
+ import { s3Storage } from '@payloadcms/storage-s3'
1793
+ import { buildConfig } from 'payload'
1794
+
1795
+ export default buildConfig({
1796
+ - plugins: [
1797
+ - s3Storage({ bucket: process.env.S3_BUCKET, collections: { media: true }, config: { region: process.env.S3_REGION } }),
1798
+ - otherPlugin(),
1799
+ - ],
1800
+ + plugins: [otherPlugin()],
1801
+ + storage: [
1802
+ + s3Storage({ bucket: process.env.S3_BUCKET, collections: { media: true }, config: { region: process.env.S3_REGION } }),
1803
+ + ],
1804
+ })
1805
+ ```
1806
+
1807
+ Run `npx @payloadcms/codemod --transform migrate-storage-adapters-to-config` to migrate automatically.
1808
+
1809
+ ### Direct uploads now use Payload's shared upload-instructions endpoint
1810
+
1811
+ Official storage packages now use `POST /api/upload-instructions` instead of provider-specific endpoints.
1812
+
1813
+ #### If you use an official Payload storage adapter
1814
+
1815
+ If you only enable `clientUploads` in your storage adapter config, no migration is needed. The Admin Panel uses the new endpoint automatically.
1816
+
1817
+ 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.
1818
+
1819
+ 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:
1820
+
1821
+ ```ts
1822
+ const response = await fetch('/api/upload-instructions', {
1823
+ method: 'POST',
1824
+ credentials: 'include',
1825
+ headers: { 'Content-Type': 'application/json' },
1826
+ body: JSON.stringify({
1827
+ collectionSlug: 'media',
1828
+ docPrefix: 'gallery',
1829
+ filename: file.name,
1830
+ filesize: file.size,
1831
+ mimeType: file.type,
1832
+ }),
1833
+ })
1834
+ ```
1835
+
1836
+ For S3 and GCS, send the file using the returned HTTP request, then pass `instructions.file` unchanged when creating the document:
1837
+
1838
+ ```ts
1839
+ const instructions = await response.json()
1840
+
1841
+ if (instructions.type !== 'http') {
1842
+ throw new Error(`Unsupported upload instruction: ${instructions.type}`)
1843
+ }
1844
+
1845
+ const { url, ...request } = instructions.request
1846
+ await fetch(url, { ...request, body: file })
1847
+
1848
+ const formData = new FormData()
1849
+ formData.append('_payload', JSON.stringify({}))
1850
+ formData.append('file', JSON.stringify(instructions.file))
1851
+
1852
+ await fetch('/api/media', {
1853
+ method: 'POST',
1854
+ credentials: 'include',
1855
+ body: formData,
1856
+ })
1857
+ ```
1858
+
1859
+ Remove manual registrations of `S3ClientUploadHandler`, `AzureClientUploadHandler`, and `GcsClientUploadHandler`; Payload now registers them. The S3 and GCS `/client` exports were also removed.
1860
+
1861
+ If you typed `clientUploads.access`, replace `ClientUploadsAccess` with `UploadInstructionsAccess`:
1862
+
1863
+ ```diff
1864
+ - import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types'
1865
+ + import type { UploadInstructionsAccess } from 'payload'
1866
+
1867
+ - const access: ClientUploadsAccess = ({ req }) => Boolean(req.user)
1868
+ + const access: UploadInstructionsAccess = ({ req }) => Boolean(req.user)
1869
+ ```
1870
+
1871
+ #### If you maintain a custom storage adapter
1872
+
1873
+ 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](../upload/storage-adapters) for the full contract.
1874
+
1875
+ 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.
1876
+
1877
+ 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.
1878
+
1879
+ Move access checks into the generator or supporting endpoint that authorizes the upload:
1880
+
1881
+ ```diff
1882
+ uploadInstructions: {
1883
+ - access,
1884
+ - generate,
1885
+ + enabled: Boolean(clientUploads),
1886
+ + generate: generateUploadInstructions({ access }),
1887
+ }
1888
+ ```
1889
+
1890
+ `useUploadHandlers().setUploadHandler` was removed. Name custom client handlers and return the same name in a `dispatch` instruction:
1891
+
1892
+ ```ts
1893
+ import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client'
1894
+
1895
+ export const MyClientUploadHandler = createClientUploadHandler({
1896
+ name: 'uploadToMyProvider',
1897
+ handler: async ({ data, file }) => {
1898
+ await uploadToMyProvider({ data, file })
1899
+ },
1900
+ })
1901
+
1902
+ uploadInstructions: {
1903
+ enabled: true,
1904
+ generate: ({ filename, filesize, mimeType }) => ({
1905
+ file: {
1906
+ uploadReference: {},
1907
+ filename,
1908
+ mimeType,
1909
+ size: filesize,
1910
+ },
1911
+ type: 'dispatch',
1912
+ name: 'uploadToMyProvider',
1913
+ data: {},
1914
+ }),
1915
+ useInAdmin: true,
1916
+ }
1917
+ ```
1918
+
1919
+ 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.
1920
+
1921
+ 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.
1922
+
1923
+ 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.
1924
+
1925
+ Finally, remove `initClientUploads` and declare its Admin handler and supporting endpoint on `uploadInstructions`:
1926
+
1927
+ ```diff
1928
+ - import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
1929
+
1930
+ - initClientUploads({
1931
+ - clientHandler: '/MyClientUploadHandler',
1932
+ - collections,
1933
+ - config,
1934
+ - enabled: true,
1935
+ - extraClientHandlerProps: () => ({ option: 'value' }),
1936
+ - serverHandler,
1937
+ - serverHandlerPath: '/my-upload-endpoint',
1938
+ - })
1939
+
1940
+ uploadInstructions: {
1941
+ + adminHandler: {
1942
+ + path: '/MyClientUploadHandler',
1943
+ + props: { option: 'value' },
1944
+ + },
1945
+ + enabled: Boolean(clientUploads),
1946
+ + endpoint: {
1947
+ + handler: serverHandler,
1948
+ + path: '/my-upload-endpoint',
1949
+ + },
1950
+ generate,
1951
+ + useInAdmin: true,
1952
+ }
1953
+ ```
1954
+
1955
+ 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.
1956
+
1957
+ ### The Uploadthing storage adapter has been removed
1958
+
1959
+ 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.
1960
+
1961
+ ```diff
1962
+ - import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
1963
+ + import { s3Storage } from '@payloadcms/storage-s3'
1964
+
1965
+ export default buildConfig({
1966
+ storage: [
1967
+ - uploadthingStorage({
1968
+ - collections: { media: true },
1969
+ - options: { token: process.env.UPLOADTHING_TOKEN },
1970
+ - }),
1971
+ + s3Storage({
1972
+ + collections: { media: true },
1973
+ + bucket: process.env.S3_BUCKET,
1974
+ + config: { region: process.env.S3_REGION },
1975
+ + }),
1976
+ ],
1977
+ })
1978
+ ```
1979
+
1980
+ The `migrate-storage-adapters-to-config` codemod no longer recognizes `uploadthingStorage` — migrate uploadthing configs to another adapter manually.
1981
+
1982
+ ### Azure client uploads now use the Azure Blob SDK — update your CORS rules
1983
+
1984
+ `@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).
1985
+
1986
+ **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`.
1987
+
1988
+ Update the CORS rules on your storage account (Storage account → **Resource sharing (CORS)** → Blob service) so that they allow:
1989
+
1990
+ | Field | Value |
1991
+ | --------------- | ---------------------------------------------------------- |
1992
+ | Allowed origins | Your site origin (e.g. `https://example.com`) |
1993
+ | Allowed methods | `GET,PUT,OPTIONS` (add `HEAD` if reading blobs in-browser) |
1994
+ | Allowed headers | `*` (or at minimum `x-ms-*,content-type,content-length`) |
1995
+ | Exposed headers | `*` |
1996
+ | Max age | `3600` |
1997
+
1998
+ If you already set `Allowed headers` to `*`, no change is required.
1999
+
2000
+ 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:
2001
+
2002
+ ```diff
2003
+ azureStorage({
2004
+ - clientUploads: { chunkLargeFiles: true },
2005
+ + clientUploads: true,
2006
+ // ...
2007
+ })
2008
+ ```
2009
+
2010
+ 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).
2011
+
2012
+ ### Sharp is now an optional file transformer #sharp-moved-out-of-core-into-payloadcmstransformer-sharp
2013
+
2014
+ Image resizing is no longer built into `payload` itself. It now ships as an official [file transformer](../upload/transformers), `@payloadcms/transformer-sharp`, that you register under `upload.transformers`.
2015
+
2016
+ This affects four things:
2017
+
2018
+ 1. The top-level `sharp` config property has been removed. Passing a Sharp instance now happens through `sharpTransformer({ sharp })` instead.
2019
+ 2. Per-collection Sharp options — `constructorOptions`, `formatOptions`, `resizeOptions`, `trimOptions`, and `withMetadata` — have been removed from `CollectionConfig['upload']`. They're now authored through `sharpTransformer({ collections })`.
2020
+ - `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.
2021
+ 3. `sharp` is no longer a dependency of `payload` at all (previously a `devDependency`, not a runtime `dependency`). Install `@payloadcms/transformer-sharp` explicitly.
2022
+ 4. 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.
2023
+
2024
+ ```sh
2025
+ pnpm add @payloadcms/transformer-sharp
2026
+ ```
2027
+
2028
+ ```diff
2029
+ + import { sharpTransformer } from '@payloadcms/transformer-sharp'
2030
+ import { buildConfig } from 'payload'
2031
+
2032
+ export default buildConfig({
2033
+ collections: [
2034
+ {
2035
+ slug: 'media',
2036
+ upload: {
2037
+ - resizeOptions: { width: 2048, height: 2048 },
2038
+ - imageSizes: [{ name: 'thumbnail', width: 400, height: 300 }],
2039
+ },
2040
+ },
2041
+ ],
2042
+ - sharp,
2043
+ upload: {
2044
+ + transformers: [
2045
+ + sharpTransformer({
2046
+ + sharp,
2047
+ + collections: {
2048
+ + media: {
2049
+ + resizeOptions: { width: 2048, height: 2048 },
2050
+ + variants: [{ name: 'thumbnail', width: 400, height: 300 }],
2051
+ + },
2052
+ + },
2053
+ + }),
2054
+ + ],
2055
+ },
2056
+ })
2057
+ ```
2058
+
2059
+ 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.
2060
+
2061
+ 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.
2062
+
2063
+ If you inject a custom Sharp build or version, pass it the same way:
2064
+
2065
+ ```diff
2066
+ - buildConfig({ sharp: myCustomSharp })
2067
+ + sharpTransformer({ sharp: myCustomSharp })
2068
+ ```
2069
+
2070
+ `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](../upload/transformers#dynamic-request-time-resizing), and [Securing dynamic resizing](../upload/transformers#securing-dynamic-resizing) before enabling it on a publicly readable collection.
2071
+
2072
+ #### Upload image data now uses variants
2073
+
2074
+ 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:
2075
+
2076
+ ```diff
2077
+ - doc.sizes?.thumbnail?.url
2078
+ + doc.variants?.thumbnail?.url
2079
+
2080
+ - where: { 'sizes.thumbnail.filename': { equals: 'photo-400x300.png' } }
2081
+ + where: { 'variants.thumbnail.filename': { equals: 'photo-400x300.png' } }
2082
+
2083
+ - select: { sizes: { thumbnail: true } }
2084
+ + select: { variants: { thumbnail: true } }
2085
+ ```
2086
+
2087
+ 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 })`.
2088
+
2089
+ Existing documents must be migrated. Create the predefined migration for your database adapter and run it:
2090
+
2091
+ <Banner type="warning">
2092
+ Payload 4 uses `variants` as the top-level field for generated upload
2093
+ variants. If an upload collection already defines a custom field named
2094
+ `variants`, rename that field before you run the predefined migration. Choose
2095
+ a new name, update your config, and write a custom migration that moves the
2096
+ existing custom data from `variants` to the new field. Then run the predefined
2097
+ `sizes-to-variants` migration. If legacy `sizes` data or columns and custom
2098
+ `variants` data or columns are both present, the predefined migration stops
2099
+ before it changes data or indexes. It cannot choose a new name or merge this
2100
+ custom data for you.
2101
+ </Banner>
2102
+
2103
+ ```sh
2104
+ # MongoDB
2105
+ payload migrate:create --file @payloadcms/db-mongodb/sizes-to-variants
2106
+ # Postgres
2107
+ payload migrate:create --file @payloadcms/db-postgres/sizes-to-variants
2108
+ # Vercel Postgres
2109
+ payload migrate:create --file @payloadcms/db-vercel-postgres/sizes-to-variants
2110
+ # SQLite
2111
+ payload migrate:create --file @payloadcms/db-sqlite/sizes-to-variants
2112
+ # Cloudflare D1
2113
+ payload migrate:create --file @payloadcms/db-d1-sqlite/sizes-to-variants
2114
+
2115
+ payload migrate
2116
+ ```
2117
+
2118
+ **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.
2119
+
2120
+ 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.
2121
+
2122
+ #### The file endpoint changes once any transformer is registered
2123
+
2124
+ Registering any transformer under `upload.transformers` — including `sharpTransformer()` with dynamic resizing off — routes every file request through the transformer-aware handler:
2125
+
2126
+ - A file must belong to an upload document to be served. A file that exists in storage without a matching document now returns `404`.
2127
+ - 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](../upload/transformers#access-control).
2128
+ - Responses produced by a transformer always carry Payload's CORS headers, which `modifyResponseHeaders` can't override.
2129
+
2130
+ 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.
2131
+
2132
+ ### Storage prefixes are now always included
2133
+
2134
+ 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`.
2135
+
2136
+ **Who is affected:**
2137
+
2138
+ - 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`).
2139
+ - Anyone who set `alwaysInsertFields` explicitly — the option no longer exists on any storage adapter or plugin option type.
2140
+ - 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.
2141
+
2142
+ **How to migrate:**
2143
+
2144
+ - Remove `alwaysInsertFields` from your storage adapter or `cloudStoragePlugin` config — it has no effect and no longer exists on the option types.
2145
+ - 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.
2146
+
2147
+ ### Upload collections retain originals and file history
2148
+
2149
+ 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.
2150
+
2151
+ **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.
2152
+
2153
+ ### File size types have been consolidated ([#16593](https://github.com/payloadcms/payload/pull/16593))
2154
+
2155
+ `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.
2156
+
2157
+ ```diff
2158
+ - import type { FileSizeImproved } from 'payload'
2159
+ + import type { FileSize } from 'payload'
2160
+ ```
2161
+
2162
+ ### Image size admin controls have been consolidated ([#16593](https://github.com/payloadcms/payload/pull/16593))
2163
+
2164
+ 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.
2165
+
2166
+ ```diff
2167
+ {
2168
+ name: 'thumbnail',
2169
+ width: 400,
2170
+ height: 300,
2171
+ admin: {
2172
+ - disableListColumn: true,
2173
+ - disableGroupBy: true,
2174
+ + disabled: { column: true, groupBy: true },
2175
+ },
2176
+ }
2177
+ ```
2178
+
2179
+ Run `npx @payloadcms/codemod --transform migrate-disabled-fields` to migrate automatically.
2180
+
2181
+ ### MongoDB queries no longer use facet aggregation ([#16612](https://github.com/payloadcms/payload/pull/16612))
2182
+
2183
+ The `useFacet` option in `mongooseAdapter` config has been removed. Payload no longer uses `$facet` aggregation, so the option has no effect.
2184
+
2185
+ ```diff
2186
+ mongooseAdapter({
2187
+ connectOptions: {
2188
+ - useFacet: false,
2189
+ },
2190
+ })
2191
+ ```
2192
+
2193
+ ### Search plugin API paths are now derived automatically ([#16597](https://github.com/payloadcms/payload/pull/16597))
2194
+
2195
+ The deprecated `apiBasePath` option on the search plugin has been removed. The plugin now derives the correct path from the root config automatically.
2196
+
2197
+ Remove any `apiBasePath` key from your search plugin config.
2198
+
2199
+ ### API keys created before v3.46.0 must be regenerated ([#16628](https://github.com/payloadcms/payload/pull/16628))
2200
+
2201
+ 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.
2202
+
2203
+ **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.
2204
+
2205
+ ### API key fields now use a factory
2206
+
2207
+ 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.
2208
+
2209
+ ```diff
2210
+ - import { baseAPIKeyFields } from 'payload'
2211
+ + import { createAPIKeyFields } from 'payload'
2212
+
2213
+ export const MyCollection = {
2214
+ fields: [
2215
+ - ...baseAPIKeyFields,
2216
+ + ...createAPIKeyFields(),
2217
+ ],
2218
+ }
2219
+ ```
2220
+
2221
+ If you previously cloned or modified `baseAPIKeyFields`, pass field overrides to `createAPIKeyFields()` instead:
2222
+
2223
+ ```ts
2224
+ createAPIKeyFields({
2225
+ apiKeyField: {
2226
+ admin: {
2227
+ components: { Field: './APIKeyField#APIKeyField' },
2228
+ },
2229
+ },
2230
+ includeEnableAPIKey: false,
2231
+ })
2232
+ ```
2233
+
2234
+ ### MCP plugin configuration and authentication have changed
2235
+
2236
+ `@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.
2237
+
2238
+ **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 }`.
2239
+
2240
+ ```diff
2241
+ mcpPlugin({
2242
+ collections: {
2243
+ - posts: { enabled: true },
2244
+ - users: { enabled: { find: true } },
2245
+ + // posts is exposed automatically, no entry needed
2246
+ + users: {
2247
+ + tools: {
2248
+ + create: false,
2249
+ + delete: false,
2250
+ + getCollectionSchema: false,
2251
+ + update: false,
2252
+ + },
2253
+ + }, // find only
2254
+ },
2255
+ })
2256
+ ```
2257
+
2258
+ After upgrading, collections you never listed before are reachable over MCP. Review what's exposed and disable anything that shouldn't be.
2259
+
2260
+ **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.
2261
+
2262
+ ```diff
2263
+ mcpPlugin({
2264
+ - mcp: {
2265
+ - tools: [
2266
+ - {
2267
+ - name: 'getPostScores',
2268
+ - description: 'Score recent posts',
2269
+ - parameters: z.object({ since: z.string() }).shape,
2270
+ - handler: async (args, req) => ({ content: [...] }),
2271
+ - },
2272
+ - ],
2273
+ - },
2274
+ + tools: {
2275
+ + getPostScores: defineTool({
2276
+ + description: 'Score recent posts',
2277
+ + input: z.object({ since: z.string() }),
2278
+ + }).handler(async ({ input, req }) => ({ content: [...] })),
2279
+ + },
2280
+ })
2281
+ ```
2282
+
2283
+ Prompts and resources move the same way, into top-level `prompts` and `resources` records.
2284
+
2285
+ Tool handlers now receive `authorizedMCP` in that object argument. It contains
2286
+ the authorized MCP items and the `overrideAccess` value, but not the user.
2287
+ `req.user` is now the single source of truth for the caller's identity. Unlike
2288
+ the v3 MCP API-key resolver, the default v4 resolver assigns the authenticated
2289
+ user to `req.user` before custom handlers run.
2290
+
2291
+ If a custom tool calls Payload's local API, pass `req` and
2292
+ `authorizedMCP.overrideAccess`. The local API reads the user from `req.user`:
2293
+
2294
+ ```diff
2295
+ const result = await req.payload.find({
2296
+ collection: 'posts',
2297
+ req,
2298
+ - overrideAccess: false,
2299
+ + overrideAccess: authorizedMCP.overrideAccess,
2300
+ })
2301
+ ```
2302
+
2303
+ 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.
2304
+
2305
+ 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.
2306
+
2307
+ ```ts
2308
+ defineCollectionTool({
2309
+ description: 'Publish a draft post by ID.',
2310
+ input: z.object({
2311
+ id: z.string(),
2312
+ }),
2313
+ }).handler(async ({ slug, input, req }) => {
2314
+ // slug is resolved from the tool call
2315
+ })
2316
+ ```
2317
+
2318
+ **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.
2319
+
2320
+ ```ts
2321
+ // arguments to a `createDocuments` tool call
2322
+
2323
+ // before
2324
+ { title: 'Hello', _status: 'draft', draft: true, depth: 2 }
2325
+
2326
+ // after
2327
+ {
2328
+ slug: 'posts',
2329
+ documents: [{ data: { title: 'Hello' } }],
2330
+ draft: true,
2331
+ depth: 2,
2332
+ }
2333
+ ```
2334
+
2335
+ `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`.
2336
+
2337
+ **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.
2338
+
2339
+ ```ts
2340
+ upload: {
2341
+ pasteURL: {
2342
+ allowList: [{ hostname: 'cdn.example.com', protocol: 'https' }],
2343
+ },
2344
+ }
2345
+ ```
2346
+
2347
+ **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`.
2348
+
2349
+ ```diff
2350
+ - mcpPlugin({ experimental: { tools: { auth: { enabled: true } } } })
2351
+ + mcpPlugin({
2352
+ + collections: {
2353
+ + users: {
2354
+ + tools: {
2355
+ + login: { access: () => true },
2356
+ + },
2357
+ + },
2358
+ + },
2359
+ + })
2360
+ ```
2361
+
2362
+ **`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.
2363
+
2364
+ **`overrideAuth` was replaced by `overrideGetAuthorizedMCP`.** The hook receives
2365
+ one argument object and returns an `AuthorizedMCP` instead of the old
2366
+ `MCPAccessSettings` type.
2367
+
2368
+ In v3, the resolved user was stored on `MCPAccessSettings.user`. In v4, a custom
2369
+ resolver must assign the user directly to `req.user` and return only `items` and
2370
+ `overrideAccess`. Always assign `req.user`, including `null` for an anonymous
2371
+ caller. The custom hook replaces the default authentication and item filtering,
2372
+ so it must also return only the MCP items that caller may use.
2373
+
2374
+ ```diff
2375
+ mcpPlugin({
2376
+ - overrideAuth: async (req, getDefaultMcpAccessSettings) => {
2377
+ - return getDefaultMcpAccessSettings()
2378
+ - },
2379
+ + overrideGetAuthorizedMCP: async ({ overrideAccess, pluginConfig, req }) => {
2380
+ + const user = await authenticateExternalRequest(req)
2381
+ + req.user = user ?? null
2382
+ +
2383
+ + return {
2384
+ + items: req.user ? pluginConfig.items : [],
2385
+ + overrideAccess,
2386
+ + }
2387
+ + },
2388
+ })
2389
+ ```
2390
+
2391
+ The default v4 resolver assigns `req.user` automatically when it authenticates
2392
+ a normal Payload user API key. Only custom `overrideGetAuthorizedMCP`
2393
+ implementations need to assign it themselves.
2394
+
2395
+ **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.
2396
+
2397
+ 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.
2398
+
2399
+ 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.
2400
+
2401
+ **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.
2402
+
2403
+ 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.
2404
+
2405
+ **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.
2406
+
2407
+ **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`.
2408
+
2409
+ ### Admin UI styles now use CSS and semantic tokens
2410
+
2411
+ 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.
2412
+
2413
+ **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:
2414
+
2415
+ ```diff
2416
+ - @import '~@payloadcms/ui/scss';
2417
+ ```
2418
+
2419
+ ```diff
2420
+ - @payloadcms/ui/scss
2421
+ + @payloadcms/ui/css
2422
+
2423
+ - @payloadcms/ui/scss/app.scss
2424
+ + @payloadcms/ui/css/app.css
2425
+ ```
2426
+
2427
+ **Token renames.** The `--theme-*` variables were replaced by semantic `--color-*` tokens. Update any custom CSS that referenced them:
2428
+
2429
+ ```diff
2430
+ .my-component {
2431
+ - background-color: var(--theme-bg);
2432
+ - color: var(--theme-text);
2433
+ + background-color: var(--color-bg);
2434
+ + color: var(--color-text);
2435
+ }
2436
+ ```
2437
+
2438
+ 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](../admin/customizing-css) for the full list of available tokens.
2439
+
2440
+ 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.
2441
+
2442
+ ### Ecommerce Stripe confirmations use an atomic transaction claim
2443
+
2444
+ Stripe confirmation now atomically claims the existing ecommerce transaction before creating an order,
2445
+ marking the cart as purchased, and decrementing inventory. Retries return the canonical order only when
2446
+ the succeeded transaction and order are linked in both directions. Ambiguous state fails closed.
2447
+
2448
+ PostgreSQL users must generate and run a database migration that adds `processing` to the ecommerce
2449
+ transaction status enum. No order field, unique index, or data backfill is required. The public
2450
+ `confirm-order` response continues to include `transactionID`, including on retries. This is a breaking
2451
+ security change for custom payment adapters: after provider validation, they must call the new
2452
+ `finalizeOrder` argument instead of creating orders themselves. The bundled Stripe adapter is updated in
2453
+ the same release. See the [payment confirmation guidance](../ecommerce/payments#confirmation-retries) for
2454
+ transactionless database failure behavior and reconciliation guidance.
2455
+
2456
+ ### Stripe REST proxy requires an explicit method allowlist
2457
+
2458
+ `@payloadcms/plugin-stripe` no longer accepts `rest: true`. To enable the REST proxy in v4, use
2459
+ an object with a non-empty `allowedMethods` array of exact Stripe SDK method names, such as
2460
+ `subscriptions.list`:
2461
+
2462
+ ```diff
2463
+ stripePlugin({
2464
+ - rest: true,
2465
+ + rest: {
2466
+ + allowedMethods: ['subscriptions.list'],
2467
+ + },
2468
+ })
2469
+ ```
2470
+
2471
+ Wildcards are not supported. The optional `rest.access` callback can further restrict the
2472
+ endpoint. Without it, the proxy requires an authenticated user allowed to access the Payload
2473
+ admin panel. See the [Stripe REST proxy documentation](../plugins/stripe#stripe-rest-proxy) for
2474
+ configuration and security guidance.
2475
+
2476
+ If you previously used `rest: false`, remove the property; omitting `rest` disables the proxy.
2477
+
2478
+ ### API keys: v4-only changes on top of the shared 3.x/4 security fix
2479
+
2480
+ - `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.
2481
+ - `enableAPIKey` is removed. Revoke by setting `apiKey: null`.
2482
+ - `hasAPIKey` (3.x virtual field) is removed. Use `apiKeyLast4` and `docPermissions.fields.apiKey`.
2483
+ - Custom `access.read` on the generated `apiKey` field is inert — reads are hardcoded to deny.
2484
+
2485
+ ### Uploads now reject oversized files by default
2486
+
2487
+ The Payload-wide [`upload.abortOnLimit`](../upload/overview#payload-wide-upload-options) 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.
2488
+
2489
+ If your application depends on the old truncate-on-limit behavior, set `abortOnLimit: false` explicitly:
2490
+
2491
+ ```diff
2492
+ export default buildConfig({
2493
+ upload: {
2494
+ + abortOnLimit: false,
2495
+ limits: {
2496
+ fileSize: 5000000,
2497
+ },
2498
+ },
2499
+ })
2500
+ ```
2501
+
2502
+ ### Nodemailer adapter now uses nodemailer 10 ([#18737](https://github.com/payloadcms/payload/pull/18737))
2503
+
2504
+ `@payloadcms/email-nodemailer` now depends on nodemailer 10, which ships its own types. If you pass your own `transport` created with nodemailer 9, it no longer type-checks against the adapter. Upgrade `nodemailer` and remove `@types/nodemailer`:
2505
+
2506
+ ```diff
2507
+ "dependencies": {
2508
+ - "nodemailer": "^9.1.1"
2509
+ + "nodemailer": "^10.1.1"
2510
+ },
2511
+ "devDependencies": {
2512
+ - "@types/nodemailer": "^8.0.0"
2513
+ }
2514
+ ```
2515
+
2516
+ If you use `transportOptions`, or pass no config, no changes are needed.