✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem13 min read

How to Migrate from Drupal to Strapi: A Complete Guide

April 24, 2026Updated on June 14, 2026
How to Migrate from Drupal to Strapi

When your Content Management System (CMS) couples the front end to the backend, every decision gets harder. Swapping a front-end framework means wrestling with Drupal's theme layer. Adding a mobile app means building a separate content pipeline. Multi-channel delivery becomes a project in itself.

Strapi takes a different approach. It's an open-source CMS built on Node.js that ships with both REST and GraphQL out of the box. You define your data model through the Content-Type Builder, and your content is available via API to any consumer: a React app, a mobile client, a static site generator, or a digital kiosk.

This guide walks you through building a working Strapi 5 instance that mirrors your existing Drupal content architecture, complete with imported data, migrated media, and a front end hitting the new API.

In brief:

  • Audit and map your Drupal content model before touching code.
  • Install Strapi 5 and rebuild your schema with the Content-Type Builder.
  • Export content via JSON:API, transform the payload, and import with a custom script.
  • Migrate media, swap your front end's API calls, and cut over with redirects.

Plan Your Migration Before You Write Any Code

This is the step most teams skip and regret. You sit down, spin up a fresh Strapi instance, start clicking through the Content-Type Builder, and thirty minutes later, realize your Drupal Paragraphs architecture doesn't translate cleanly. Now you're restructuring a half-built schema with test data already in place.

A written inventory before you boot Strapi saves real time.

Audit Your Existing Content Architecture

Start with your Drupal content model and document everything:

  • Content types (node bundles): Every field, its machine name, type, cardinality, and whether it's required.
  • Taxonomies: Which vocabularies exist, whether terms have a parent/child hierarchy or custom fields.
  • Paragraphs: How many Paragraph types each field allows and whether any Paragraph type itself contains a nested Paragraphs field with multiple allowed types.
  • Media and files: Volume, referenced entity types, file storage locations.
  • Users and roles: Which roles exist and what permissions they carry.
  • Drupal-specific constructs: Views, custom entities, modules that store state in the database. Flag these early because they won't map cleanly.

Export your configuration and note relationships and content volume per type so you can plan your import order.

Map Each Concept to Its Strapi Equivalent

Here's where your Drupal-to-Strapi content model mapping takes shape:

Drupal ConceptStrapi EquivalentNotes
Content type (node bundle)Collection TypeClean one-to-one mapping
FieldsStrapi field types (string, richtext, media, json, etc.)See the field type reference in the schema section
Paragraphs (single type)ComponentsRepeatable or non-repeatable based on cardinality
Paragraphs (multiple types)Dynamic ZonesOrdered list of mixed components
Taxonomy vocabularyCollection Type with a relationHierarchy and term weights need custom fields
Users and rolesUsers & Permissions pluginDrupal's single permission system splits into two Strapi systems: the Users & Permissions plugin for API access, and Admin RBAC for panel access

One critical structural limitation to flag now: Dynamic Zones cannot be placed inside Components. If any Drupal Paragraph type contains a nested Paragraphs field with multiple allowed types, that architecture needs flattening before migration.

Install and Configure Strapi 5

Fresh Strapi instance, PostgreSQL-backed, ready for your schema work. Nail the prerequisites and database wiring up front so you don't trip on them later.

Check Your Prerequisites

Strapi 5 requires Node.js v20, v22, or v24 (odd-numbered releases are not supported), plus npm, yarn, or pnpm. For your database, PostgreSQL is the recommended choice for production. SQLite works for local development, but it isn't production-safe: it doesn't enforce strict type constraints by default, and migrations behave differently across database engines. Bugs tied to those constraint differences stay hidden until a database switch, when fixing them is expensive.

Bootstrap the Project

npx create-strapi@latest my-strapi-project

This launches an interactive setup. If you prefer to script the setup for a team:

npx create-strapi@latest my-strapi-project --non-interactive --js --skip-cloud

Note that --quickstart is no longer available in Strapi 5. Use --non-interactive instead.

For a PostgreSQL setup in a single command:

npx create-strapi@latest my-strapi-project \
  --dbclient=postgres \
  --dbhost=localhost \
  --dbport=5432 \
  --dbname=strapi \
  --dbusername=strapi \
  --dbpassword=strapi \
  --no-run

Once dependencies are installed, start the dev server:

cd my-strapi-project && npm run develop

The browser opens the admin registration form. Super Admin access goes to the first registered user.

Configure the Database for Production

The database configuration lives in config/database.js and reads from environment variables:

// ./config/database.js
module.exports = ({ env }) => ({
  connection: {
    client: 'postgres',
    connection: {
      connectionString: env('DATABASE_URL'),        // overrides all below if set
      host:             env('DATABASE_HOST', 'localhost'),
      port:             env.int('DATABASE_PORT', 5432),
      database:         env('DATABASE_NAME', 'strapi'),
      user:             env('DATABASE_USERNAME', 'strapi'),
      password:         env('DATABASE_PASSWORD', 'strapi'),
      ssl: env.bool('DATABASE_SSL', false) && {
        key:                env('DATABASE_SSL_KEY', undefined),
        cert:               env('DATABASE_SSL_CERT', undefined),
        ca:                 env('DATABASE_SSL_CA', undefined),
        capath:             env('DATABASE_SSL_CAPATH', undefined),
        cipher:             env('DATABASE_SSL_CIPHER', undefined),
        rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
      },
    },
    pool: {
      min: env.int('DATABASE_POOL_MIN', 2),
      max: env.int('DATABASE_POOL_MAX', 10),
    },
  },
});

This same configuration works whether you're deploying to Strapi Cloud or self-hosting. Keep secrets in your .env file and never commit them to version control.

Rebuild Your Content Model in Strapi

Getting the schema right prevents data import pain. This section is intentionally the longest.

Build Collection Types with the Content-Type Builder

Walk through creating an Article Collection Type. In the Admin Panel, navigate to the Content-Type Builder and create a new Collection Type named "Article" with these fields: title (string), slug (UID targeting title), body (rich text), featuredImage (media, single image), and publishedAt (datetime).

Behind the scenes, the Content-Type Builder writes a schema file to src/api/article/content-types/article/schema.json:

{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": {
    "singularName": "article",
    "pluralName": "articles",
    "displayName": "Article"
  },
  "options": { "draftAndPublish": true },
  "attributes": {
    "title": { "type": "string", "required": true },
    "slug": { "type": "uid", "targetField": "title" },
    "body": { "type": "richtext" },
    "featuredImage": {
      "type": "media",
      "multiple": false,
      "allowedTypes": ["images"]
    }
  }
}

The uid field auto-prefills a URL-friendly slug from a configured target field, similar to Drupal's automatic path alias generation.

Data loss warning: Renaming a field in the Content-Type Builder UI creates a new database column and deletes the old one. Plan field names carefully before populating content.

Set Up Relations Between Content Types

Create Category and Author as separate Collection Types. Then configure the relations.

For Author → Articles (one-to-many), add this to the Article schema:

"author": {
  "type": "relation",
  "relation": "manyToOne",
  "target": "api::author.author",
  "inversedBy": "articles"
}

For Articles ↔ Categories (many-to-many):

"categories": {
  "type": "relation",
  "relation": "manyToMany",
  "target": "api::category.category",
  "inversedBy": "articles"
}

The relation parameters mappedBy and inversedBy define which side owns the relationship. The owning side uses inversedBy; the inverse side uses mappedBy.

Use Components and Dynamic Zones for Flexible Layouts

Components are reusable field groups that map well to Drupal Paragraph types. A Hero component, for example, lives at ./src/components/blocks/hero.json:

{
  "collectionName": "components_blocks_heroes",
  "info": { "displayName": "Hero", "icon": "layout" },
  "attributes": {
    "headline": { "type": "string", "required": true },
    "subheadline": { "type": "text" },
    "backgroundImage": { "type": "media", "multiple": false, "allowedTypes": ["images"] },
    "ctaLabel": { "type": "string" },
    "ctaUrl": { "type": "string" }
  }
}

A Dynamic Zone creates a flexible space where editors compose content from a mixed list of component types. This is your direct equivalent to a Drupal Paragraphs field with multiple allowed types.

Here's a Page Collection Type with a pageBlocks Dynamic Zone:

"pageBlocks": {
  "type": "dynamiczone",
  "components": [
    "blocks.hero",
    "blocks.rich-text",
    "blocks.image-gallery"
  ]
}

The key distinction: a repeatable Component constrains all instances to one type. A Dynamic Zone allows instances of different component types in a single field, which is what makes it the page builder pattern you likely need for landing pages and flexible layouts.

Export Content from Drupal

With your schema in place, pull structured data out of Drupal and shape it into something Strapi can ingest. JSON:API handles the heavy lifting, but the payload needs reshaping before it lands.

Pull Your Content via JSON:API

Drupal JSON:API is included in core since version 8.7. Enable the module at Admin → Extend or via Drush:

drush en jsonapi -y

Endpoints follow the pattern /jsonapi/{entity_type}/{bundle}. For articles:

GET /jsonapi/node/article?page[limit]=50&sort=drupal_internal__nid&filter[status][value]=1&include=field_tags,uid

The default maximum page size is 50 items. Every response includes a links object. Follow links.next.href for pagination rather than constructing URLs manually. The presence or absence of links.next is the reliable indicator of remaining pages.

Here's a minimal Node.js fetch that paginates through a resource type:

async function fetchAllDrupalNodes(baseUrl, bundle) {
  let url = `${baseUrl}/jsonapi/node/${bundle}?page[limit]=50&sort=drupal_internal__nid`;
  const allNodes = [];

  while (url) {
    const res = await fetch(url, {
      headers: { Accept: 'application/vnd.api+json' },
    });
    const json = await res.json();
    allNodes.push(...(json.data ?? []));
    url = json.links?.next?.href ?? null;
  }
  return allNodes;
}

If you need unpublished content or user data, add authentication via HTTP Basic Auth or an OAuth 2.0 module on the Drupal side.

Transform the Exported Data for Strapi's Format

Drupal's JSON:API nests fields under data.attributes and relationships under data.relationships. Strapi's REST API expects a flat data wrapper with a connect array pattern for relations.

function transformArticle(drupalNode, uuidMaps) {
  const { attributes, relationships } = drupalNode;

  return {
    data: {
      title: attributes.title,
      body: attributes.body?.processed ?? '',
      drupal_uuid: drupalNode.id,
      publishedAt: attributes.status
        ? (attributes.created ?? new Date().toISOString())
        : null,
      tags: {
        connect: (relationships?.field_tags?.data ?? [])
          .map(t => ({ id: uuidMaps.tags[t.id] }))
          .filter(t => t.id),
      },
    },
  };
}

Add a drupal_uuid text field to every Collection Type in Strapi. This becomes your idempotency key for re-runnable imports and cross-referencing source data during QA.

Import Content into Strapi

Now the transformed data goes into your new Collection Types. Order matters, relations need resolving, and the script has to be safe to re-run when something breaks halfway through a 10,000-record import.

Use the Strapi Import CLI for Strapi-Native Exports

Strapi ships with strapi export and strapi import commands for moving data between Strapi instances:

yarn strapi export --no-encrypt -f my-backup
yarn strapi import -f my-backup.tar.gz

These are useful for backups and environment promotion later, but they expect Strapi's native data format. For Drupal data, a custom import script is the more practical path.

Write a Custom Import Script for Your Transformed Data

The approach: hit the Strapi REST API with a full-access API token, loop over transformed records, and create entries with relations resolved. The authorization header uses the format bearer your-api-token.

Import order matters. Create authors and categories first, then articles with relations pointing to the correct Strapi IDs:

import 'dotenv/config';

const STRAPI_URL = process.env.STRAPI_API_URL;
const TOKEN = process.env.STRAPI_API_TOKEN;

async function strapiPost(path, body) {
  const res = await fetch(`${STRAPI_URL}/api${path}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `bearer ${TOKEN}`,
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

async function findByDrupalUuid(collection, uuid) {
  const res = await fetch(
    `${STRAPI_URL}/api/${collection}?filters[drupal_uuid][$eq]=${encodeURIComponent(uuid)}`,
    { headers: { Authorization: `bearer ${TOKEN}` } }
  );
  const json = await res.json();
  return json.data?.[0] ?? null;
}

for (const record of transformedArticles) {
  const existing = await findByDrupalUuid('articles', record.data.drupal_uuid);
  if (existing) {
    console.log(`Skipping existing: ${record.data.title}`);
    continue;
  }
  await strapiPost('/articles', record);
  console.log(`Created: ${record.data.title}`);
}

The idempotency check (find by drupal_uuid before creating) makes this script safe to run multiple times. If a record already exists, it skips creation.

Migrate Media Files to the Media Library

Download referenced files from Drupal during the transform step, then upload them to Strapi's Media Library via the upload endpoint. The upload API accepts multipart/form-data with the file in a field named files:

const form = new FormData();
form.append('files', file, 'hero.jpg');
form.append('fileInfo', JSON.stringify({ alternativeText: 'Hero image' }));

const res = await fetch(`${STRAPI_URL}/api/upload`, {
  method: 'POST',
  body: form,
  headers: { Authorization: `Bearer ${TOKEN}` },
});
const [uploaded] = await res.json();
const mediaId = uploaded.id; // Attach this to content entries

The response is an array. Capture result[0].id and pass it as the media field value when creating content entries.

Before running bulk uploads, configure your upload provider in config/plugins.js. Files go to whichever provider is active at upload time. If you migrate with the local provider and later switch to S3 or Cloudinary, you'll need to re-upload everything.

Wire Up Your Frontend and Go Live

Content is in Strapi. Now the front end needs updating to hit the new endpoints, permissions need configuring, and you need a cutover plan that doesn't tank your SEO.

Update Your API Calls

Replace Drupal JSON:API endpoints with Strapi's /api/[collection] pattern. Here's a before and after for fetching a blog list:

Before (Drupal):

const res = await fetch('/jsonapi/node/article?include=field_image,field_tags');
const { data } = await res.json();
const title = data[0].attributes.title; // nested under attributes

After (Strapi 5):

const res = await fetch('http://localhost:1337/api/articles?populate=featuredImage&populate=tags', {
  headers: { Authorization: `Bearer ${API_TOKEN}` },
});
const { data } = await res.json();
const title = data[0].title; // flat, no .attributes nesting

Two things to watch: Strapi 5's response shape is flat (fields live directly on data, not under data.attributes), and populate defaults need to be explicit. Every relation, media field, and component needs a populate parameter. Strapi ships both REST and GraphQL; pick whichever your team already uses.

For Dynamic Zones specifically, Strapi 5 requires on fragments to populate component types explicitly. This is a breaking change from v4 that requires manual updates to all Dynamic Zone queries.

Configure Roles, Permissions, and Authentication

All content types are private by default. To enable public read access, navigate to Settings → Users & Permissions Plugin → Roles, select the Public role, and enable find and findOne for each content type you want publicly accessible.

For server-to-server calls (like a Next.js build step), issue API tokens from Settings → API Tokens. Use read-only tokens for public-facing fetches and full-access tokens only where writes are needed. For authenticated end-user flows, the Users & Permissions plugin provides JWT-based authentication via /api/auth/local.

Set Up Redirects, Smoke-Test, and Cut Over

Preserve SEO by mapping old Drupal URLs to new ones with 301 redirects at the server or edge level. Map /node/[nid] and Pathauto aliases to your new URL structure. Never redirect old pages to the homepage in bulk; map each to its most relevant equivalent.

Run through a QA checklist before flipping DNS:

  • Rich text rendering correctly (Drupal stores HTML; Strapi 5 uses a block format).
  • Relations populated (check with ?populate=*).
  • Media URLs resolving from the configured provider.
  • robots.txt allowing crawls on production.
  • Sitemap regenerated and submitted to Google Search Console.

Lower your DNS TTL values a day or two ahead of cutover so changes propagate quickly once you flip the switch. After DNS propagates, verify SSL is active, resubmit your sitemap, and keep the old Drupal environment accessible on an internal URL for reference.

What to Tackle After the Cutover

The arc is straightforward: plan your content model mapping, build the schema in Strapi, export from Drupal via JSON:API, transform and import with a custom script, then swap your front end's API calls and cut over.

From here, consider setting up managed hosting, adding internationalization if your Drupal site was multilingual, or browsing the Strapi Marketplace for plugins that replace Drupal modules you relied on (SEO metadata, content versioning, XML sitemaps).

Paul BratslavskyDeveloper Advocate

Related Posts

How To Migrate Your Project From Strapi 4 to Strapi 5
Blog·20 min read

How To Migrate Your Project From Strapi 4 to Strapi 5

Migrating from Strapi v4 to v5 can seem daunting, but it becomes a manageable process with the right approach and...

·October 1, 2024
Why I moved from Drupal to Strapi - A user story
Definitions & benefits·5 min read

Why I moved from Drupal to Strapi - A user story

Strapi’s note: this is a guest post from , a great Strapi user in Delhi, willing to share his experience on Strapi....

·July 30, 2019
Headless CMS vs Drupal
Ecosystem·9 min read

Headless CMS vs Drupal: How to Choose Between the Two

A headless CMS and offer powerful features but cater to different needs. A headless CMS gives you the freedom to build...

·September 1, 2024