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 Concept | Strapi Equivalent | Notes |
|---|---|---|
| Content type (node bundle) | Collection Type | Clean one-to-one mapping |
| Fields | Strapi field types (string, richtext, media, json, etc.) | See the field type reference in the schema section |
| Paragraphs (single type) | Components | Repeatable or non-repeatable based on cardinality |
| Paragraphs (multiple types) | Dynamic Zones | Ordered list of mixed components |
| Taxonomy vocabulary | Collection Type with a relation | Hierarchy and term weights need custom fields |
| Users and roles | Users & Permissions plugin | Drupal'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-projectThis 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-cloudNote 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-runOnce dependencies are installed, start the dev server:
cd my-strapi-project && npm run developThe 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 -yEndpoints 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,uidThe 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.gzThese 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 entriesThe 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 attributesAfter (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 nestingTwo 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.txtallowing 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).