Adobe Experience Manager (AEM) has long been an enterprise-grade content management solution, but its complexity, licensing costs, and infrastructure requirements are driving many organizations to explore modern alternatives. Payload CMS has emerged as a compelling headless CMS option that offers flexibility, developer experience, and cost efficiency that AEM struggles to match.
This guide walks you through the complete process of migrating from AEM to Payload CMS, covering content modeling, data migration, API integration, and deployment strategies.
Why Organizations Are Moving Away from AEM
- Cost: AEM licensing can exceed $200,000+ annually for enterprise deployments
- Complexity: Steep learning curve with Java/OSGi architecture
- Infrastructure: Heavy resource requirements for on-premise or Cloud Service
- Flexibility: Monolithic architecture limits modern frontend frameworks
Understanding Payload CMS
Payload CMS is an open-source, headless content management system built with TypeScript and Node.js. It provides a powerful admin panel, flexible content modeling, and a robust API—all while giving you complete control over your data and infrastructure.
Key Advantages of Payload Over AEM
| Feature | AEM | Payload CMS |
|---|---|---|
| Licensing Cost | $200K+/year | Free (MIT License) |
| Technology Stack | Java, OSGi, HTL | TypeScript, Node.js, React |
| API-First | Partial (Content Services) | Native REST & GraphQL |
| Deployment Options | Cloud Service or On-Premise | Any Node.js host, Docker, Serverless |
| Database | JCR (Apache Oak) | MongoDB or PostgreSQL |
| Admin Customization | Complex (Coral UI, Granite) | Simple React components |
| Learning Curve | Steep (months) | Moderate (weeks) |
Migration Planning
A successful AEM to Payload migration requires careful planning across several dimensions:
1 Content Audit
Inventory all content types, components, and assets in your AEM instance. Identify:
- Page templates and their components
- Content Fragments and Experience Fragments
- DAM assets (images, videos, documents)
- Translations and language copies
- Custom workflows and business logic
2 Content Model Mapping
Map AEM content structures to Payload collections:
- AEM Page Components → Payload Collections with blocks
- Content Fragments → Payload Collections
- DAM Assets → Payload Media library
- Tags → Payload relationship fields
3 Integration Assessment
Evaluate external integrations that need to be migrated:
- Adobe Target personalization
- Adobe Analytics tracking
- Third-party DAM connections
- Custom Sling servlets and APIs
Setting Up Payload CMS
Start by creating a new Payload project:
# Create a new Payload project
npx create-payload-app@latest my-payload-cms
# Navigate to the project
cd my-payload-cms
# Install dependencies
npm install
Configure Your Database
Payload supports MongoDB and PostgreSQL. For most AEM migrations, MongoDB provides a flexible document structure similar to JCR:
// payload.config.ts
import { buildConfig } from 'payload/config';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
admin: {
user: 'users',
},
db: mongooseAdapter({
url: process.env.MONGODB_URI,
}),
collections: [
// Your collections will go here
],
});
Content Modeling in Payload
Recreating AEM Page Structures
AEM pages with components can be modeled as Payload collections with blocks:
// collections/Pages.ts
import { CollectionConfig } from 'payload/types';
import { HeroBlock } from '../blocks/Hero';
import { ContentBlock } from '../blocks/Content';
import { CTABlock } from '../blocks/CTA';
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'status', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
position: 'sidebar',
},
},
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock, CTABlock],
},
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'image', type: 'upload', relationTo: 'media' },
],
},
],
};
Migrating Content Fragments
AEM Content Fragments map directly to Payload collections:
// collections/Articles.ts (Content Fragment equivalent)
export const Articles: CollectionConfig = {
slug: 'articles',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'author',
type: 'relationship',
relationTo: 'authors',
},
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
{
name: 'content',
type: 'richText',
},
{
name: 'publishDate',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
],
};
Exporting Content from AEM
Use AEM's Content Services or QueryBuilder to export content as JSON:
# Query all pages under /content/mysite
curl -u admin:admin \
"http://localhost:4502/bin/querybuilder.json?\
path=/content/mysite&\
type=cq:Page&\
p.limit=-1" \
-o aem-pages.json
# Export Content Fragments
curl -u admin:admin \
"http://localhost:4502/bin/querybuilder.json?\
path=/content/dam/mysite/content-fragments&\
type=dam:Asset&\
p.limit=-1" \
-o aem-content-fragments.json
Export Assets from DAM
# Create a package of DAM assets
# In AEM Package Manager, create package with filter:
# /content/dam/mysite
# Or use Asset Share Commons for bulk export
curl -u admin:admin \
"http://localhost:4502/content/dam/mysite.assetdownload.zip" \
-o dam-assets.zip
Data Transformation Script
Create a migration script to transform AEM JSON to Payload format:
// migrate.ts
import fs from 'fs';
import payload from 'payload';
interface AEMPage {
'jcr:path': string;
'jcr:title': string;
'jcr:content': {
'jcr:title': string;
'cq:template': string;
'sling:resourceType': string;
[key: string]: any;
};
}
async function migratePages() {
// Initialize Payload
await payload.init({
secret: process.env.PAYLOAD_SECRET,
local: true,
});
// Read AEM export
const aemData = JSON.parse(
fs.readFileSync('./aem-pages.json', 'utf-8')
);
for (const aemPage of aemData.hits as AEMPage[]) {
const content = aemPage['jcr:content'];
// Transform AEM page to Payload format
const payloadPage = {
title: content['jcr:title'] || aemPage['jcr:title'],
slug: extractSlug(aemPage['jcr:path']),
status: 'draft',
layout: transformComponents(content),
meta: {
title: content['jcr:title'],
description: content['jcr:description'],
},
};
try {
await payload.create({
collection: 'pages',
data: payloadPage,
});
console.log(`Migrated: ${payloadPage.slug}`);
} catch (error) {
console.error(`Failed: ${payloadPage.slug}`, error);
}
}
process.exit(0);
}
function extractSlug(jcrPath: string): string {
return jcrPath
.replace('/content/mysite/', '')
.replace(/\/jcr:content.*/, '');
}
function transformComponents(content: any): any[] {
const layout: any[] = [];
// Map AEM components to Payload blocks
Object.entries(content).forEach(([key, value]) => {
if (key.startsWith('par') && typeof value === 'object') {
// Parse parsys children
Object.entries(value as object).forEach(([compKey, compValue]) => {
const block = mapComponentToBlock(compValue as any);
if (block) layout.push(block);
});
}
});
return layout;
}
function mapComponentToBlock(component: any): any | null {
const resourceType = component['sling:resourceType'];
switch (resourceType) {
case 'mysite/components/hero':
return {
blockType: 'hero',
heading: component.heading,
subheading: component.subheading,
backgroundImage: component.fileReference,
};
case 'mysite/components/text':
return {
blockType: 'content',
richText: component.text,
};
// Add more component mappings
default:
console.warn(`Unmapped component: ${resourceType}`);
return null;
}
}
migratePages();
Asset Migration
Migrate DAM assets to Payload's media library:
// migrate-assets.ts
import fs from 'fs';
import path from 'path';
import payload from 'payload';
async function migrateAssets() {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
local: true,
});
const assetsDir = './extracted-dam-assets';
const files = getAllFiles(assetsDir);
for (const filePath of files) {
const fileName = path.basename(filePath);
const fileBuffer = fs.readFileSync(filePath);
try {
await payload.create({
collection: 'media',
data: {
alt: extractAltFromPath(filePath),
},
file: {
data: fileBuffer,
name: fileName,
mimetype: getMimeType(fileName),
size: fileBuffer.length,
},
});
console.log(`Uploaded: ${fileName}`);
} catch (error) {
console.error(`Failed: ${fileName}`, error);
}
}
}
function getAllFiles(dir: string): string[] {
const files: string[] = [];
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
files.push(...getAllFiles(fullPath));
} else if (isAssetFile(item.name)) {
files.push(fullPath);
}
}
return files;
}
migrateAssets();
Frontend Integration
Once your content is in Payload, integrate with your frontend framework:
// Next.js example: fetching pages from Payload
import { getPayloadClient } from '../payload/payloadClient';
export async function getStaticProps({ params }) {
const payload = await getPayloadClient();
const pages = await payload.find({
collection: 'pages',
where: {
slug: { equals: params.slug },
status: { equals: 'published' },
},
});
if (!pages.docs.length) {
return { notFound: true };
}
return {
props: {
page: pages.docs[0],
},
revalidate: 60,
};
}
export async function getStaticPaths() {
const payload = await getPayloadClient();
const pages = await payload.find({
collection: 'pages',
where: { status: { equals: 'published' } },
limit: 1000,
});
return {
paths: pages.docs.map((page) => ({
params: { slug: page.slug },
})),
fallback: 'blocking',
};
}
Deployment Options
Payload offers flexible deployment options compared to AEM:
| Platform | Best For | Monthly Cost |
|---|---|---|
| Payload Cloud | Managed hosting, zero DevOps | $35-500+ |
| Vercel + MongoDB Atlas | Serverless, auto-scaling | $20-200 |
| Docker + VPS | Full control, cost-effective | $10-100 |
| AWS/GCP/Azure | Enterprise scale, compliance | $50-500+ |
Migration Considerations
- Personalization: If using Adobe Target, evaluate alternatives like LaunchDarkly or Optimizely
- Analytics: Adobe Analytics can still work with Payload via client-side integration
- Workflows: AEM workflows need to be rebuilt using Payload hooks or external workflow engines
- Training: Plan for team training on TypeScript/Node.js stack
Post-Migration Checklist
- ✅ All content types migrated and verified
- ✅ Assets uploaded with proper metadata
- ✅ URL redirects configured (old AEM URLs to new)
- ✅ API endpoints tested for all consumers
- ✅ Admin users created with appropriate roles
- ✅ Preview and draft functionality working
- ✅ Search functionality implemented
- ✅ Performance testing completed
- ✅ Backup and disaster recovery configured
Need Help with Your AEM to Payload Migration?
WegWorks has deep expertise in both AEM and modern headless CMS platforms. We can help you plan and execute a seamless migration that minimizes disruption while maximizing the benefits of your new platform. Contact us to discuss your migration project.