---
title: "Payload CMS 2.0 → 3.0 Migration Guide"
aliases: [payload-v2-to-v3, payload-migration-v3, payload-upgrade-3]
tags: [payloadcms, migration, nextjs, breaking-changes, upgrade]
sources: [raw/migration-guide__v3.md]
created: 2026-05-15
updated: 2026-05-15
---

# Payload CMS 2.0 → 3.0 Migration Guide

v3 replatforms the Admin Panel from React Router SPA → **Next.js App Router** with full React Server Component support. Core APIs are unchanged; the blast radius is in the HTTP layer, admin config, and custom components.

---

## Installation

```bash
npx create-payload-app   # scaffold new project to copy auto-generated files from
pnpm i next react react-dom payload @payloadcms/ui @payloadcms/next
pnpm remove express nodemon @payloadcms/bundler-webpack @payloadcms/bundler-vite
```

- All `@payloadcms/*` package versions **must be in sync** — no mixed versions.
- Run DB migrations if you have existing data (see [[wiki/payloadcms/database-migrations|Database Migrations]]).
- If uploads use `formatOptions`/`imageSizes`/`resizeOptions`, install `sharp` and add it to `buildConfig({ sharp })`.

---

## Breaking Changes — Config

| What changed | Before | After |
|---|---|---|
| Bundler | `admin.bundler: webpackBundler()` | Remove entirely — Next.js bundles now |
| Secret | `payload.init({ secret })` | Move to `buildConfig({ secret: process.env.PAYLOAD_SECRET })` |
| Client env vars | `PAYLOAD_PUBLIC_*` | `NEXT_PUBLIC_*` |
| `req` object | extends Express Request (`req.headers['x']`) | extends Web Request (`req.headers.get('x')`) |
| Admin CSS | `admin.css` / `admin.scss` | Edit `(payload)/custom.scss`; or use `admin.components.providers` |
| `admin.indexHTML` | present | removed |
| Collection admin hooks | `collection.admin.hooks.beforeDuplicate` | field-level `hooks.beforeDuplicate` array |
| `upload.staticDir` | relative path | **must be absolute** (`path.resolve(dirname, '../uploads')`) |
| `upload.staticURL` | present | removed; use `generateFileURL` |
| `admin.favicon` | string path | `admin.icons: [{ path, sizes }]` |
| `admin.meta.ogImage` | string | `admin.meta.openGraph.images: []` |
| `admin.livePreview.url` args | `documentInfo` | `collectionConfig` / `globalConfig` |
| Logout/inactivity routes | `admin.logoutRoute`, `admin.inactivityRoute` | `admin.routes.logout`, `admin.routes.inactivity` |
| `custom` property | available on client | **server-only**; use `admin.custom` for client |
| `hooks.afterError` | single function | **array** of functions + new args (`req`, `res`) |
| Public dir | `./src/public` | `./public` (Next.js convention) |
| Localized sub-fields | manual `localized: true` | auto-stripped when parent is localized; use `allowLocalizedWithinLocalized` flag to disable |

---

## Custom Components

- All Payload UI components moved: `'payload'` → `'@payloadcms/ui'`
- Components now defined as **file paths** (strings), not imported references:
  ```diff
  - Button: MyComponent
  + Button: '/src/components/Logout#MyComponent'
  ```
- All custom components are **server-rendered by default** — add `'use client'` for state/hooks.
- `admin.description` no longer accepts React components; use `admin.components.edit.Description` / `admin.components.elements.Description` / `admin.components.Description`.
- Array `RowLabel` and Collapsible `Label` only accept component **paths** (no plain strings).
- View keys are now **camelCase** (`Edit` → `edit`, `Default` → `default`).
- Custom views use `{ Component: './path/to/View.tsx' }` shape; custom root views go under `edit.root`.
- View Tab `isActive`/`href` no longer receive `match`/`location` (React Router only); use `usePathname()` instead.
- `pillLabel` → `tab.Pill` component path.
- `react-i18n` `Trans` → `@payloadcms/ui` `Translation` component with `elements` map.

---

## Endpoints

- Handler signature: `(req)` only — no `res`, no `next`; return `Response.json(...)`.
- `req.params` → `req.routeParams`.
- `req.data` / `req.locale` are **not** auto-resolved; call utilities manually:
  ```ts
  import { addDataAndFileToRequest, addLocalesToRequest } from '@payloadcms/next/utilities'
  await addDataAndFileToRequest(req)
  await addLocalesToRequest(req)
  ```

---

## React Hooks

| Hook | Change |
|---|---|
| `useTitle` | Removed; use `const { title } = useDocumentInfo()` |
| `useDocumentInfo` | `collection`/`global` → `collectionSlug`/`globalSlug` |
| `useConfig` | Import from `@payloadcms/ui`; returns `ClientConfig` (not `SanitizedConfig`); destructure as `const { config } = useConfig()` |
| `useCollapsible` | `collapsed` → `isCollapsed`; `withinCollapsible` → `isWithinCollapsible` |
| `useTranslation` | No options; use full `group:key` syntax (`t('general:cancel')`) |

---

## Types

```diff
- Fields → FormState
- BlockField → BlocksField
- BlockFieldProps → BlocksFieldProps
```

---

## Email Adapters

Email extracted into adapters — no longer bundled:

```ts
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
// or
import { resendAdapter } from '@payloadcms/email-resend'

buildConfig({ email: nodemailerAdapter({ ... }) })
```

- Rate-limiting removed (only available with custom Express server).

---

## Plugins

- All plugins use **named exports** with `Plugin` suffix:
  ```diff
  - import seo from '@payloadcms/plugin-seo'
  + import { seoPlugin } from '@payloadcms/plugin-seo'
  ```

### Cloud Storage

`@payloadcms/plugin-cloud-storage` adapters extracted to standalone packages:

| Service | New package |
|---|---|
| AWS S3 | `@payloadcms/storage-s3` |
| Azure | `@payloadcms/storage-azure` |
| GCS | `@payloadcms/storage-gcs` |
| Vercel Blob | `@payloadcms/storage-vercel-blob` |

```ts
import { s3Storage } from '@payloadcms/storage-s3'
plugins: [s3Storage({ collections: { media: true }, bucket: process.env.S3_BUCKET, config: { ... } })]
```

### Plugin Field Overrides (form-builder, redirects)

`fields` option changed from array → function receiving `defaultFields`:
```ts
fields: ({ defaultFields }) => [...defaultFields, { name: 'custom', type: 'text' }]
```

### Payload Cloud Plugin

```diff
- @payloadcms/plugin-cloud → payloadCloud()
+ @payloadcms/payload-cloud → payloadCloudPlugin()
```

---

## Reserved Field Names

Avoid these in Collections/Globals — Payload will sanitize them:

- General: `file`, `_id` (Mongo), `__v` (Mongo)
- Internal (avoid `_` prefix generally): `_order`, `_path`, `_uuid`, `_parent_id`, `_locale`
- Auth: `salt`, `hash`, `password`, `email`, `username`, `apiKey`, `resetPasswordToken`, `resetPasswordExpiration`
- Upload: `filename`, `mimetype`, `filesize`, `width`, `height`, `url`, `thumbnailURL`, `focalX`, `focalY`, `sizes`

---

## Key Takeaways

- **Admin Panel** is now a Next.js App Router app — remove all Webpack/Vite bundler config.
- **`secret`** moves from `payload.init()` → `buildConfig()`.
- **Custom components** are file paths (strings), server-rendered by default; add `'use client'` for hooks.
- **Endpoint handlers** return `Response.json()` — no Express `res` arg; parse `req.data` manually.
- **Cloud storage** plugins are now standalone packages per provider.
- **Plugin imports** are named exports with `Plugin` suffix.
- **React hooks** import from `@payloadcms/ui`, not `'payload'`.
- All `@payloadcms/*` packages must be **version-synced**.

---

## Related

- [[wiki/payloadcms/migration-guide-overview|Migration Guides — Overview]]
- [[wiki/payloadcms/migration-troubleshooting|Migration & Troubleshooting]]
- [[wiki/payloadcms/installation|Installation]]
- [[wiki/payloadcms/configuration|Payload Config — Overview]]
- [[wiki/payloadcms/custom-components-authoring|Custom Components — Authoring Guide]]
- [[wiki/payloadcms/environment-vars|Environment Variables]]
- [[wiki/payloadcms/plugins|Official Plugins]]
- [[wiki/payloadcms/upload|Upload & Media]]
- [[wiki/payloadcms/email|Email — Adapters]]

---

## Sources

- `raw/migration-guide__v3.md`
- https://payloadcms.com/docs/migration-guide/v3
