How to Migrate from Drupal to Payload CMS: A Step-by-Step Guide

Published on by

Execudea

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

DrupalPayload CMS
Content type (node bundle)Collection
FieldField (text, number, richText, date, select, etc.)
Entity referenceRelationship field
Taxonomy vocabularyCollection (e.g. categories, tags) + relationship field
ParagraphsBlocks field
Field collections / multi-value groupsArray field
Media entities / filesUpload-enabled collection (media)
Menus, site settingsGlobals
Users, roles, permissionsAuth-enabled users collection + access control functions
Revisions, workflowsVersions and drafts
ViewsAPI queries in your frontend (Local API, REST or GraphQL)
Path aliasesslug field + redirects
Multisite / Domain AccessMulti-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_* and taxonomy_term_data tables 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/123 paths and old taxonomy URLs. The official @payloadcms/plugin-redirects plugin 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.

Frequently Asked Questions

Can I migrate from Drupal 7 to Payload CMS?

Yes. Drupal 7 has no core JSON:API, so content is usually exported directly from the database (node, field_data_* and taxonomy tables) into JSON, then imported into Payload with the Local API.

Will I lose SEO rankings when moving from Drupal to Payload?

Not if you keep URLs identical where possible, 301-redirect every changed URL, migrate meta titles and descriptions, and resubmit your sitemap. Ranking losses almost always come from missing redirects.

How do Drupal Paragraphs map to Payload CMS?

Each Paragraphs type becomes a block in a Payload blocks field. Editors get the same flexible, reorderable page sections, with fully typed data.

Does Payload CMS support Drupal-style multisite?

Yes. The official multi-tenant plugin lets one Payload instance serve multiple sites with isolated content and per-tenant access control.