Migrating from Joomla to Strapi 5 is a substantial project, especially if your current setup mixes content, routing, templates, and extension logic in ways that are hard to untangle. This guide shows a practical path through it, with concrete database mappings, export queries, and migration scripts so you can move deliberately instead of discovering edge cases at cutover time.
In brief:
- Audit and export your Joomla database directly using SQL queries and Node.js scripts, because Joomla's own API won't cover everything you need.
- Set up Strapi 5 with the Content-Type Builder, mapping Joomla tables to Collection Types before writing migration code.
- Write a migration script that uses Strapi's REST API to import content, upload media, and preserve relationships.
- Handle SEO redirects at the hosting or frontend layer since Strapi itself doesn't render pages or manage routing.
Why Developers Move from Joomla to Strapi
Understanding the limitations of Joomla's architecture and what Strapi offers as an alternative will help you decide whether this migration makes sense for your project and team.
Where Joomla Falls Short for Modern Development
Joomla's monolithic PHP architecture couples data, routing, templating, and extensions tightly together. If you've tried using Joomla as a headless CMS, you've probably run into the usual friction: rebuilding routing, SEO metadata, and extension integrations in your frontend application can become a project in its own right.
Joomla 4 and 5 introduced a Joomla API, but it only covers seven core components out of the box: articles, categories, banners, contacts, languages, configuration, and content history. Menus output HTML rather than structured JSON. Custom fields live in a separate #__fields table and aren't automatically included in article API responses.
Every third-party extension needs a custom webservice plugin to expose its data. The API also requires API tokens for all requests, even for public content, and offers no granular API permissions despite Joomla's powerful ACL system.
Then there's extension dependency sprawl. A typical Joomla site accumulates dozens of extensions over time, each with its own update cycle, compatibility concerns, and potential security surface. When you need to expose that data to a React or Next.js frontend, you're writing a webservice plugin for every one of them.
What Strapi 5 Gives You
Strapi 5 is an open-source headless CMS built on Node.js and TypeScript that takes a fundamentally different approach. Every content type you create automatically gets both REST and GraphQL API endpoints, no plugins required. The Builder docs let you visually design your data schema, and you can use any frontend framework: Next.js, Nuxt, Astro, or anything else that can make HTTP requests.
A key architectural change in Strapi 5 is the Document concept. Every content entry now has a documentId, a stable 24-character alphanumeric string that persists across versions, localizations, and operations. This replaces reliance on numeric id values that could change during duplication or import/export.
The Document API uses this identifier for CRUD operations, and the REST API response format has been flattened, so fields sit directly on data rather than being nested inside data.attributes.
For hosting, Strapi Cloud provides managed infrastructure with automated backups and scalability, or you can self-host on any server running a supported Node.js version.
Prerequisites
Before starting your migration, confirm you have the following:
- Node.js v20, v22, or v24 (Active LTS or Maintenance LTS only; odd-number releases are not supported)
- npm v6+, yarn, or pnpm
- Python (required if using SQLite as your database)
- Git for version control
- Access to your Joomla MySQL database: credentials plus phpMyAdmin or CLI access
- A code editor (VS Code, WebStorm, etc.)
Strapi 5 supports SQLite, MySQL (8.0+), MariaDB (10.3+), and PostgreSQL (14.0+) as database clients. MongoDB and cloud-native databases like Amazon Aurora are not supported.
Find your Joomla table prefix in DB prefix. You'll need this for every export query.
For full environment details, see the Quick Start and DB config.
Step 1: Audit and Export Your Joomla Data
This step combines planning, assessment, and extraction in one pass. The goal is to pull everything out of Joomla's database in a format that Strapi can consume.
Map Joomla's Database Tables
These are the tables you care about for a content migration:
| Table | Contents |
|---|---|
#__content | Articles (title, alias, introtext, fulltext, images, metadesc, metadata) |
#__categories | Categories (nested set model with lft/rgt/level/path) |
#__fields | Custom field definitions (name, type, params) |
#__fields_values | Custom field values per content item |
#__tags | Tags (also nested set) |
#__contentitem_tag_map | Article-to-tag relationships |
#__users | User accounts (username, email, password hash) |
#__usergroups | User groups |
#__user_usergroup_map | User-to-group assignments |
Note that #__content.images stores a JSON string with image_intro, image_intro_alt, image_fulltext, and image_fulltext_alt fields. In that JSON, image_intro and image_fulltext are relative paths from Joomla's root, while the _alt fields contain plain alt text strings.
Also be aware that #__fields_values.item_id has a schema issue. It can be varchar or BIGINT depending on the installation, so flexible type handling helps here.
Export Content as JSON
Joomla doesn't have a clean headless export tool. Querying the database directly is usually the most reliable option. Here's a Node.js script using mysql2 that exports published articles with their categories:
const mysql = require('mysql2/promise');
const fs = require('fs').promises;
async function exportJoomlaArticles() {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'your_user',
password: 'your_password',
database: 'joomla_db'
});
const [rows] = await connection.execute(`
SELECT JSON_ARRAYAGG(
JSON_OBJECT(
'id', c.id,
'title', c.title,
'alias', c.alias,
'introtext', c.introtext,
'fulltext', c.fulltext,
'state', c.state,
'created', c.created,
'images', c.images,
'metadesc', c.metadesc,
'category', JSON_OBJECT(
'id', cat.id,
'title', cat.title,
'alias', cat.alias,
'path', cat.path
)
)
) AS articles_json
FROM jos_content c
LEFT JOIN jos_categories cat ON c.catid = cat.id
WHERE c.state = 1
`);
await connection.end();
await fs.writeFile('articles.json', JSON.stringify(rows[0].articles_json, null, 2));
console.log('Export complete: articles.json');
}
exportJoomlaArticles().catch(console.error);Replace jos_ with your actual table prefix. The JSON_ARRAYAGG and JSON_OBJECT functions require MySQL 5.7.8+ per the JSON docs.
Run separate queries for tags, custom fields, and users. Keeping each export in its own JSON file makes the migration easier to reason about.
Download Your Media Files
Joomla media files live in the /images directory. Pull the entire folder down:
rsync -avz user@joomla-server:/path/to/joomla/images ./joomla-media/The paths referenced in #__content.images are relative, for example images/articles/my-photo.jpg, so you'll need to parse that JSON column and match filenames to the downloaded files during the upload step.
Step 2: Set Up Your Strapi 5 Project
Install Strapi
Run the creation command from the Quick Start guide:
npx create-strapi@latest my-strapi-projectThe CLI prompts you to log in to Strapi Cloud, which you can skip with --skip-cloud if you're self-hosting, and choose a database. Use PostgreSQL for production migrations and SQLite for local testing.
For a non-interactive setup, which is useful in CI or scripted environments:
npx create-strapi@latest my-strapi-project --dbclient postgres --dbhost localhost --dbport 5432 --dbname strapi --dbusername strapi --dbpassword yourpassword --skip-cloudStart the development server:
cd my-strapi-project && npm run developCreate Your Content Types
Open the Admin Panel (default: http://localhost:1337/admin) and use the Content-Type Builder to create your Collection Types. The builder is only available in development mode; it's disabled in production by design.
Map your Joomla structures like this:
| Joomla | Strapi 5 |
|---|---|
#__content (articles) | Article Collection Type with Rich Text fields for introtext and fulltext |
#__categories | Category Collection Type (self-referencing relation for nesting) |
#__tags | Tag Collection Type |
#__fields / custom fields | Custom fields on the Article type, or a Dynamic Zone for flexible layouts |
#__users | Users & Permissions plugin (end users) or admin users |
Add metaTitle (Text), metaDescription (Text), and ogImage (Media) fields to your Article type. You can populate metaDescription from Joomla's metadesc and metadata columns, and metaTitle from the metadata column.
For categories, create a self-referencing relation (Category has many Categories) to replicate Joomla's nested hierarchy. You can also store the path field as a text field to preserve Joomla's category paths for redirect mapping.
Configure API Permissions
Your migration script needs write access to Strapi's API. Create an API token:
- Go to Settings → Global settings → API Tokens
- Click Create new API Token
- Set the type to Full access
- Set the duration based on your migration timeline; 30 days is usually sufficient
- Click Save and copy the token immediately, because it's shown only once
Use this token in the Authorization: Bearer header for all migration API calls. For details on token configuration, see the API Tokens.
You can also configure public read permissions for your content types after migration. Navigate to Settings → Users & Permissions → Roles → Public and enable find and findOne for each content type your frontend needs to access.
Step 3: Write Your Migration Script
This is the core of the migration. You have two approaches: the REST API, which is usually the safer choice, or direct database migration files.
Option A: Use Strapi's REST API to Import Content
This approach respects Strapi's validation, lifecycle hooks, and data integrity checks. Here's a Node.js script that reads the exported Joomla JSON, transforms it, and POSTs it to Strapi:
const fs = require('fs').promises;
const STRAPI_URL = 'http://localhost:1337';
const API_TOKEN = 'your-api-token-here';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`
};
async function migrateCategories() {
const categories = JSON.parse(await fs.readFile('categories.json', 'utf-8'));
const categoryMap = new Map(); // joomla_id → strapi_documentId
for (const cat of categories) {
const response = await fetch(`${STRAPI_URL}/api/categories`, {
method: 'POST',
headers,
body: JSON.stringify({
data: {
title: cat.title,
slug: cat.alias,
description: cat.description
}
})
});
const result = await response.json();
categoryMap.set(cat.id, result.data.documentId);
}
return categoryMap;
}
async function migrateArticles(categoryMap) {
const articles = JSON.parse(await fs.readFile('articles.json', 'utf-8'));
for (const article of articles) {
const body = {
data: {
title: article.title,
slug: article.alias,
introtext: article.introtext,
fulltext: article.fulltext,
metaTitle: article.title,
metaDescription: article.metadesc,
category: categoryMap.get(article.category?.id) || null
}
};
const response = await fetch(`${STRAPI_URL}/api/articles`, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!response.ok) {
console.error(`Failed to migrate: ${article.title}`, await response.text());
} else {
const result = await response.json();
console.log(`Migrated: ${article.title} → ${result.data.documentId}`);
}
}
}
async function main() {
const categoryMap = await migrateCategories();
await migrateArticles(categoryMap);
}
main().catch(console.error);A critical detail: Strapi 5's REST API returns documentId as the primary identifier, not the numeric id. Store the mapping between Joomla's numeric IDs and Strapi's documentId values, because you'll need it to attach tags, media, and relations in subsequent steps.
Order matters. Create categories first, then articles, so you can reference category documentId values, then tags, then media.
Option B: Use Database Migration Files
If you prefer writing directly to the database, Strapi supports migration files in ./database/migrations/. Note that this approach is experimental in Strapi 5 per the Migrations docs.
Files follow an alphabetical naming convention:
2026.03.22T00.00.00.seed-joomla-articles.jsHere's a sample migration file:
'use strict';
async function up(knex) {
const articles = require('../../data/articles.json');
for (const article of articles) {
await knex('articles').insert({
title: article.title,
slug: article.alias,
introtext: article.introtext,
fulltext: article.fulltext,
created_at: article.created,
updated_at: new Date().toISOString(),
published_at: article.state === 1 ? new Date().toISOString() : null
});
}
}
module.exports = { up };Strapi 5 has no built-in down or rollback function. The up() function receives a transactional Knex instance, so any failure auto-rolls back the entire migration.
A word of caution: Strapi will delete unknown tables during its automated schema migrations. Only use database migration files for data that matches existing Content-Type schemas. Test thoroughly in a staging environment before running against production.
Upload Media Files to Strapi's Media Library
Strapi 5 requires a two-step process for media: upload the file first, then reference it when creating or updating content. You cannot upload files during entry creation. This is a breaking change from v4.
Step 1: Upload the file:
const fs = require('fs');
const path = require('path');
async function uploadImage(filePath) {
const formData = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer]);
formData.append('files', blob, path.basename(filePath));
const response = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
},
body: formData
});
const uploadedFiles = await response.json();
return uploadedFiles[0].id;
}Step 2: Link the uploaded file to a content entry:
await fetch(`${STRAPI_URL}/api/articles/${documentId}`, {
method: 'PUT',
headers,
body: JSON.stringify({
data: {
coverImage: fileId // reference the uploaded file's numeric ID
}
})
});Parse Joomla's images JSON column to extract the image_intro and image_fulltext paths, match them to your downloaded media files, upload each one, and then attach the returned file IDs to the corresponding articles. For full endpoint details, see the Upload API.
Step 4: Migrate Users and Set Up Roles
Import User Accounts via the Users and Permissions Plugin
Strapi's Users & Permissions manages end-user accounts. These are separate from Admin Panel accounts. Create users via the REST API:
async function migrateUsers() {
const users = JSON.parse(await fs.readFile('users.json', 'utf-8'));
for (const user of users) {
const tempPassword = generateSecurePassword(); // generate a temporary password
const response = await fetch(`${STRAPI_URL}/api/auth/local/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: user.username,
email: user.email,
password: tempPassword
})
});
if (response.ok) {
console.log(`User created: ${user.username} — password reset required`);
}
}
}You cannot migrate Joomla password hashes directly. Joomla uses PHP hashing, while Strapi uses the bcryptjs package{:. Cross-platform implementation differences in byte handling and cipher details make hash verification between the two systems unreliable. In practice, password reset emails for migrated users are the safe path.
Before running bulk user imports, disable sign-ups and email confirmation in the Admin Panel to avoid triggering confirmation emails during migration.
If you need to allow custom fields during registration, configure them explicitly in config/plugins.js per the allowedFields change:
module.exports = {
'users-permissions': {
config: {
register: {
allowedFields: ['displayName', 'bio'],
},
},
},
};Map Joomla User Groups to Strapi Roles
Here's a concrete mapping between Joomla's default user groups and Strapi's role system:
| Joomla User Group | Strapi Role |
|---|---|
| Super Users | Super Admin (full access, permissions cannot be restricted) |
| Managers / Administrators | Custom admin roles (Editor or Author) |
| Registered / Author | Authenticated role (Users & Permissions plugin) |
| Public | Public role (unauthenticated access) |
Strapi's RBAC docs describe three built-in admin roles: Author (create and manage own content), Editor (create content and manage or publish any content), and Super Admin (unrestricted). RBAC is available in both the Community edition and paid plans, so you can configure granular permissions for each content type without an Enterprise license.
Step 5: Handle SEO: URLs and Redirects
Map Joomla's SEF URLs to Your New Route Structure
Joomla's SEF URLs typically follow patterns like /menu-alias/category-id-category-alias/article-id-article-alias, sometimes with a .html suffix if the "Add Suffix to URL" option is enabled. Both numeric IDs and aliases are embedded in these URLs, so export both during your audit step for redirect mapping.
Since Strapi is a headless CMS and doesn't render a frontend, redirects are handled at the hosting or CDN layer, such as Netlify or Vercel, or in your frontend framework's router, such as Next.js, Nuxt, or Nginx.
Implement 301 Redirects
Choose the method that matches your hosting setup:
Netlify _redirects file (place in your site's publish directory):
# Joomla SEF URL patterns → new routes
/component/content/article/* /articles/:splat 301
/category/*/articles /blog/:splat 301
/index.php / 301Next.js next.config.js. Note that permanent: true returns HTTP 308, not 301. Most search engines treat this equivalently, but if you need strict 301 behavior, use Nginx or Netlify instead. Per the Next.js redirects:
module.exports = {
async redirects() {
return [
{
source: '/component/content/article/:id/:slug',
destination: '/articles/:slug',
permanent: true,
},
{
source: '/:path*.html',
destination: '/:path*',
permanent: true,
},
];
},
};Vercel limits cap you at 1,024 redirects. For large Joomla sites with thousands of articles, Next.js Middleware for database-backed dynamic redirects may be more practical.
Nginx rewrite block is the most powerful option for complex Joomla URL patterns:
server {
listen 80;
server_name example.com;
location = /index.php {
return 301 /;
}
rewrite ^/component/content/article/([0-9]+)/(.+)$ /articles/$2 permanent;
rewrite ^/(.+)\.html$ /$1 permanent;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}Per the rewrite docs, the permanent flag returns a true 301, and PCRE regex gives you full pattern matching for Joomla's numeric-ID-embedded SEF URLs.
Migrate SEO Metadata
If you added metaTitle, metaDescription, and ogImage fields to your Strapi Article Content-Type, as recommended in Step 2, populate them during your migration script. Joomla stores this data in #__content.metadesc and #__content.metadata:
const body = {
data: {
title: article.title,
metaTitle: article.title,
metaDescription: article.metadesc || '',
// Parse article.metadata JSON for additional SEO fields
}
};Step 6: Connect a Frontend
This is where the headless architecture pays off. Strapi provides the API, and you decide how to render it.
Choose Your Framework
Three popular options with strong Strapi integration:
- Next.js: the most popular choice with Strapi, supporting SSR, SSG, and ISR. Strapi offers an official starter and a beginner guide.
- Nuxt: the Vue ecosystem equivalent, with an official module that provides composables for data fetching.
- Astro: useful for content-heavy sites where performance is the priority. It fetches at build time by default with optional server-side rendering.
Fetch Content from Strapi's API
Strapi 5 does not populate relations by default. You must explicitly request them using the populate parameter per the Populate docs.
Here's a minimal Next.js server component:
// app/articles/page.js
export default async function ArticlesPage() {
const response = await fetch(
`${process.env.STRAPI_URL}/api/articles?populate=*`,
{
cache: 'no-store',
headers: {
'Authorization': `Bearer ${process.env.STRAPI_API_TOKEN}`
}
}
);
const { data: articles } = await response.json();
return (
<div>
{articles.map(article => (
<div key={article.documentId}>
<h2>{article.title}</h2>
<div dangerouslySetInnerHTML={{ __html: article.introtext }} />
</div>
))}
</div>
);
}Two Strapi 5 details matter here: use article.documentId as the key, not article.id, and access fields directly on the data object, not nested under data.attributes as in v4.
The wildcard populate=* only fetches first-level relations. For nested relations, like an article's category's parent category, use bracket syntax:
GET /api/articles?populate[category][populate]=parentCategoryFor complex queries in migration scripts, the qs library is recommended for building query strings. See Strapi's Populate guide for detailed examples.
Also remember that all content types are private by default. Either enable public read access in the Admin Panel under Settings → Users & Permissions → Roles → Public, or authenticate every request with an API token stored in server-side environment variables.
Step 7: Test and Go Live
Content Integrity Checks
Run through this checklist before cutting over:
- Article count in Strapi matches published articles exported from Joomla
- Categories are linked correctly with parent-child relationships preserved
- Media URLs resolve and images display correctly
- Rich Text and HTML content renders properly in your frontend (HTML content is stored in Joomla's
introtextandfulltextcolumns, so your frontend needs to sanitize or render it correctly) - Tags are attached to the right articles
- Custom field values migrated accurately
- SEO metadata (
metaTitle,metaDescription) is populated - 301 redirects resolve correctly for a sample of old Joomla URLs
Test API Responses
Use Strapi's Query Builder to verify that your API returns the expected data shape. Test with filters, sorting, and pagination to confirm everything works:
{
sort: ['title:asc'],
filters: {
category: {
slug: { $eq: 'news' }
}
},
populate: {
category: { fields: ['title', 'slug'] },
coverImage: { fields: ['url', 'alternativeText'] }
},
pagination: { pageSize: 10, page: 1 },
status: 'published'
}Deploy to Strapi Cloud or Self-Host
For Strapi Cloud, the connection was established during npx create-strapi@latest if you logged in during setup. A .strapi-cloud.json file links your local project to the cloud infrastructure. Logging in activates a 30-day Growth plan trial with features like Live Preview, Releases, and Content History. See the Cloud docs for details.
For self-hosting, build and start in production mode:
NODE_ENV=production npm run build
NODE_ENV=production npm startNever use npm run develop in production, because it enables auto-reloading and exposes the Content-Type Builder. The start command runs the production server with the builder disabled.
Minimum hardware: 1 CPU core, 2GB RAM, 8GB disk (recommended: 2+ cores, 4GB+, 32GB+). Supported operating systems include Ubuntu 20.04+ LTS, Debian 10.x+, RHEL 8.x+, and macOS 11.x+. Windows Server is not supported. For the complete deployment guide, see the Deployment docs.
Conclusion
If you're moving from Joomla to Strapi 5, the safest path is usually to treat the migration like an application project, not a content export task. Audit the Joomla database carefully, model your Content-Types before importing anything, and test redirects and media handling early. Most migration pain shows up in relationships, user handling, and old URL patterns, not in the first happy-path import script.
From here, the next step is to run a small pilot migration on staging with a subset of articles, categories, and media. Once that looks right, you can scale the scripts up, connect your frontend, and plan the final cutover with a rollback option.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.