Migrating from Drupal to Payload CMS means mapping your Drupal content types, taxonomies and paragraphs to Payload collections and blocks, exporting content through Drupal's JSON:API (or directly from the database), transforming it into Payload's shape, importing it with Payload's Local API, and then preserving every URL with redirects so you keep your search rankings. Below is the exact process we follow on real migrations.
Why Teams Are Moving from Drupal to Payload CMS
Drupal is a capable platform, but many teams hit the same walls: major-version upgrades that feel like rebuilds, PHP module dependencies that block upgrades, and a theming layer that fights modern frontend frameworks. Drupal 7 reached end of life in January 2025 and Drupal 9 in November 2023, so a large number of sites are now facing a forced migration either way.
If you have to rebuild anyway, Payload CMS is an attractive destination:
- Code-first configuration. Your schema lives in TypeScript files under version control, not in database configuration exports.
- Native Next.js integration. Payload 3 installs directly into a Next.js app, so admin panel, API and frontend can live in one codebase.
- Full type safety. Payload generates TypeScript types from your collections, so your frontend knows the exact shape of your content.
- Your choice of database. PostgreSQL, MongoDB or SQLite through official adapters.
- Fine-grained access control written as plain functions, which is easier to reason about than stacking permission modules.
Step 1: Audit Your Drupal Site
Before writing any code, build an inventory. Most migration overruns come from content structures nobody remembered existed.
- List every content type and its fields, including field type, cardinality and whether it is required.
- List every taxonomy vocabulary and how it is referenced.
- List Paragraphs types (if you use the Paragraphs module) and which content types use them.
- Export all URL aliases (
path_aliastable) and existing redirects. - Count nodes per type, media files and total file size.
- Note custom modules and what business logic they contain. That logic becomes Payload hooks or frontend code.
- Pull your top landing pages from Google Search Console. These pages get extra QA after the migration.
Step 2: Map Drupal Concepts to Payload
Most Drupal concepts have a clean equivalent in Payload:
| Drupal | Payload CMS |
|---|---|
| Content type (node bundle) | Collection |
| Field | Field (text, number, richText, date, select, etc.) |
| Entity reference | Relationship field |
| Taxonomy vocabulary | Collection (e.g. categories, tags) + relationship field |
| Paragraphs | Blocks field |
| Field collections / multi-value groups | Array field |
| Media entities / files | Upload-enabled collection (media) |
| Menus, site settings | Globals |
| Users, roles, permissions | Auth-enabled users collection + access control functions |
| Revisions, workflows | Versions and drafts |
| Views | API queries in your frontend (Local API, REST or GraphQL) |
| Path aliases | slug field + redirects |
| Multisite / Domain Access | Multi-tenant plugin |
If you run multiple Drupal sites from one install, read our guide to multi-tenancy in Payload CMS before designing your schema.
Step 3: Model Your Payload Collections
Here is a typical Drupal "Article" content type rebuilt as a Payload collection. Keep a drupalId field on every migrated collection. It makes re-running imports and resolving references far easier, and you can remove it after cutover.
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: { useAsTitle: 'title' },
versions: { drafts: true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'summary', type: 'textarea' },
{ name: 'content', type: 'richText' },
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'publishedAt', type: 'date' },
{
name: 'drupalId',
type: 'text',
index: true,
admin: { readOnly: true, position: 'sidebar' },
},
],
}
Step 4: Export Content from Drupal
You have two realistic options:
- JSON:API (recommended). Enable the core JSON:API module and fetch each bundle at
/jsonapi/node/article. You get structured data, relationships and file URLs with no SQL work. It works on Drupal 8 and later. - Direct database export. Required for Drupal 7, or when JSON:API is too slow on very large sites. Query the
node,field_data_*andtaxonomy_term_datatables and dump them to JSON.
Export everything to local JSON files first. Never import directly from the live Drupal site. Local files make the import repeatable and fast to debug.
// scripts/export-drupal.ts
import fs from 'node:fs/promises'
const BASE = process.env.DRUPAL_URL // e.g. https://old-site.com
async function exportBundle(type: string) {
let url: string | undefined =
`${BASE}/jsonapi/node/${type}?page[limit]=50&include=field_image,field_tags`
const items: unknown[] = []
const included: unknown[] = []
while (url) {
const res = await fetch(url, { headers: { Accept: 'application/vnd.api+json' } })
if (!res.ok) throw new Error(`Export failed: ${res.status} ${url}`)
const json = await res.json()
items.push(...json.data)
if (json.included) included.push(...json.included)
url = json.links?.next?.href
}
await fs.writeFile(`./export/${type}.json`, JSON.stringify({ items, included }, null, 2))
console.log(`Exported ${items.length} ${type} nodes`)
}
await fs.mkdir('./export', { recursive: true })
await exportBundle('article')
Step 5: Import into Payload with the Local API
Payload's Local API talks to the database directly without HTTP, which makes it the fastest and safest way to import. Import in dependency order: media and taxonomies first, then users, then content, so relationships can be resolved.
// scripts/import-posts.ts (run with: npx payload run scripts/import-posts.ts)
import fs from 'node:fs/promises'
import { getPayload } from 'payload'
import config from '@payload-config'
const payload = await getPayload({ config })
const { items } = JSON.parse(await fs.readFile('./export/article.json', 'utf8'))
async function findByDrupalId(collection: 'categories' | 'media', drupalId: string) {
const res = await payload.find({
collection,
where: { drupalId: { equals: drupalId } },
limit: 1,
depth: 0,
})
return res.docs[0]?.id
}
for (const node of items) {
const a = node.attributes
const existing = await payload.find({
collection: 'posts',
where: { drupalId: { equals: node.id } },
limit: 1,
depth: 0,
})
const tagIds = await Promise.all(
(node.relationships.field_tags?.data ?? []).map((t: { id: string }) =>
findByDrupalId('categories', t.id),
),
)
const imageRef = node.relationships.field_image?.data
const heroImage = imageRef ? await findByDrupalId('media', imageRef.id) : undefined
const data = {
title: a.title,
slug: (a.path?.alias ?? `/node/${a.drupal_internal__nid}`).replace(/^\/+/, ''),
summary: a.body?.summary ?? '',
publishedAt: a.created,
categories: tagIds.filter(Boolean),
heroImage,
drupalId: node.id,
_status: a.status ? 'published' : 'draft',
}
if (existing.docs[0]) {
await payload.update({ collection: 'posts', id: existing.docs[0].id, data })
} else {
await payload.create({ collection: 'posts', data })
}
}
console.log('Import complete')
process.exit(0)
Because the script looks up drupalId first, you can run it as many times as you like. That is essential, because you will re-run it several times while fixing edge cases, and once more right before cutover to catch content edited in the meantime.
Converting Drupal HTML to Payload rich text
Drupal body fields are HTML. Payload's default Lexical editor stores structured JSON, so body content needs converting. The @payloadcms/richtext-lexical package ships HTML-to-Lexical conversion utilities you can call inside your import script. Clean the HTML first: strip inline styles, empty paragraphs and legacy shortcodes, and rewrite internal links from /node/123 to their new slugs.
Step 6: Migrate Media Files
Download each file from Drupal and upload it into your media collection with the Local API, storing the Drupal file ID in drupalId. If you serve media from S3, DigitalOcean Spaces or similar, configure Payload's storage adapter before importing so files land in the right place from the start. Always migrate alt text. It is easy to lose and it matters for both accessibility and image SEO.
Step 7: Preserve URLs and SEO
This is where most migrations lose traffic. Treat it as a hard requirement, not a cleanup task.
- Keep URLs identical wherever possible. Store the Drupal alias as the Payload slug and mirror the path structure in your Next.js routes.
- 301-redirect every URL that changes, including
/node/123paths and old taxonomy URLs. The official@payloadcms/plugin-redirectsplugin lets editors manage redirects in the admin panel. - Migrate meta titles and descriptions from Drupal's Metatag module into fields managed by
@payloadcms/plugin-seo. See our guide on Payload CMS SEO. - Regenerate your XML sitemap and submit it in Search Console. Here is how to build a dynamic sitemap in Payload CMS.
- Keep canonical tags, hreflang and structured data at parity with the old site.
Step 8: QA and Cutover
- Compare node counts in Drupal against document counts in Payload for every collection.
- Crawl the old site and test every URL against the new site. Each should return 200 or a single 301 hop, never a chain or a 404.
- Manually review your top 50 landing pages from Search Console.
- Freeze content in Drupal, run the import one final time, then switch DNS.
- Monitor Search Console coverage and 404 reports daily for the first two weeks.
How Long Does a Drupal to Payload Migration Take?
A brochure site with a handful of content types can move in two to four weeks. A content-heavy site with many content types, Paragraphs, multilingual content and custom modules usually takes two to four months, most of it spent on content modeling, rich text conversion and QA rather than the import itself.
Need Help With Your Migration?
Execudea builds and migrates production sites on Payload CMS. If you are planning a move off Drupal, WordPress or another CMS, our Payload CMS development team can audit your current site and give you a clear migration plan, timeline and cost.