When we moved our Next.js MDX blog to Ghost on AWS, we needed to preserve Markdown editability in Ghost's editor. Most migration guides convert content to HTML, making it non-editable. Here's the complete migration tool we built to handle MDX files, images, and Ghost's Mobiledoc format.
Understanding the Migration Challenge
Our Next.js blog had MDX files with frontmatter metadata and images stored in public directories. Ghost uses a format called Mobiledoc for content storage. If you import HTML into Ghost, the content becomes read-only. To maintain editability, you need to preserve content as Markdown "cards" within Mobiledoc.
The Mobiledoc structure for editable Markdown looks like this:
{
version: '0.3.1',
markups: [],
atoms: [],
cards: [['markdown', {
cardName: 'markdown',
markdown: 'Your **markdown** content here'
}]],
sections: [[10, 0]]
}
This structure tells Ghost to treat the content as editable Markdown rather than static HTML. Without this specific format, your content becomes locked and can only be edited as HTML blocks.
The Complete Migration Script
Here's the full migration script that handles MDX files and creates Ghost posts with proper Mobiledoc format:
#!/usr/bin/env node
/**
* MDX to Ghost Migration Script
* Migrates MDX content to Ghost CMS preserving Markdown editability
*/
require('dotenv').config({ quiet: true });
const fs = require('fs').promises;
const path = require('path');
const matter = require('gray-matter');
const GhostAdminAPI = require('@tryghost/admin-api');
// Configuration from environment variables
const config = {
ghostUrl: process.env.GHOST_URL || 'https://your-ghost-site.com',
ghostAdminKey: process.env.GHOST_ADMIN_KEY,
contentDir: process.env.MDX_CONTENT_DIR || './content/blog',
imagesDir: process.env.MDX_IMAGES_DIR || './public/images/blog',
dryRun: process.argv.includes('--dry-run'),
testMode: process.argv.includes('--test'),
maxPosts: process.argv.includes('--test') ? 2 : undefined,
};
// Initialize Ghost Admin API
let api = null;
if (!config.dryRun) {
if (!config.ghostAdminKey) {
console.error('❌ Please set GHOST_ADMIN_KEY environment variable');
console.error(' Get this from Ghost Admin > Settings > Integrations');
process.exit(1);
}
api = new GhostAdminAPI({
url: config.ghostUrl,
key: config.ghostAdminKey,
version: 'v5.0',
});
}
/**
* Process a single MDX file
*/
async function processMdxFile(filePath) {
const content = await fs.readFile(filePath, 'utf8');
const { data: frontmatter, content: body } = matter(content);
// Get slug from filename
const slug = path.basename(filePath, '.mdx');
// Process images in content
const processedBody = await processImages(body, slug);
// Create Ghost post data with Mobiledoc format
const postData = {
title: frontmatter.title || 'Untitled',
slug: slug,
mobiledoc: JSON.stringify({
version: '0.3.1',
markups: [],
atoms: [],
cards: [
[
'markdown',
{
cardName: 'markdown',
markdown: processedBody,
},
],
],
sections: [[10, 0]],
}),
status: frontmatter.published === false ? 'draft' : 'published',
meta_title: frontmatter.metaTitle || frontmatter.title,
meta_description: frontmatter.metaDescription || frontmatter.description,
};
// Handle dates
if (frontmatter.date || frontmatter.publishedAt) {
const date = new Date(frontmatter.date || frontmatter.publishedAt);
if (!isNaN(date.getTime())) {
postData.published_at = date.toISOString();
}
}
// Add excerpt if present
if (frontmatter.description || frontmatter.excerpt) {
const excerpt = frontmatter.description || frontmatter.excerpt;
postData.custom_excerpt =
excerpt.length > 300 ? excerpt.substring(0, 297) + '...' : excerpt;
}
// Handle tags and categories
const tags = [];
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
tags.push(...frontmatter.tags);
}
if (frontmatter.categories && Array.isArray(frontmatter.categories)) {
tags.push(...frontmatter.categories);
}
if (tags.length > 0) {
postData.tags = [...new Set(tags)].map((tag) => ({ name: tag }));
}
// Add author if specified
if (frontmatter.author) {
postData.authors = [
{
email: process.env.GHOST_AUTHOR_EMAIL || 'admin@example.com',
},
];
}
return postData;
}
/**
* Process images in content
* Updates image paths for Ghost compatibility
*/
async function processImages(content, slug) {
let processedContent = content;
// Find all image references
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const images = [...content.matchAll(imageRegex)];
for (const match of images) {
const [fullMatch, altText, imageUrl] = match;
// Skip external images
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
continue;
}
// For local images, update path to Ghost's expected format
// Ghost stores images in /content/images/
const imageName = path.basename(imageUrl);
const newImagePath = `/content/images/${new Date().getFullYear()}/${String(
new Date().getMonth() + 1,
).padStart(2, '0')}/${imageName}`;
processedContent = processedContent.replace(
fullMatch,
``,
);
// Note: Actual image files need to be uploaded separately to Ghost
console.log(` ℹ Image referenced: ${imageName} → ${newImagePath}`);
}
return processedContent;
}
/**
* Check if a post already exists
*/
async function postExists(slug) {
if (config.dryRun) return false;
try {
const existing = await api.posts.browse({
filter: `slug:${slug}`,
limit: 1,
});
return existing.length > 0;
} catch (error) {
return false;
}
}
/**
* Create a post in Ghost
*/
async function createPost(postData) {
// Check for existing post
if (await postExists(postData.slug)) {
console.log(` ⊙ Skipped: ${postData.title} (already exists)`);
return { status: 'skipped' };
}
if (config.dryRun) {
console.log(` ✓ Would create: ${postData.title}`);
return { status: 'dry-run' };
}
try {
const post = await api.posts.add(postData);
console.log(` ✓ Created: ${post.title}`);
return { status: 'created', post };
} catch (error) {
console.error(` ✗ Failed: ${error.message}`);
return { status: 'failed', error };
}
}
/**
* Main migration function
*/
async function migrate() {
console.log('\n🚀 Starting Ghost Migration');
console.log(`📁 Content directory: ${config.contentDir}`);
console.log(`🔧 Mode: ${config.dryRun ? 'DRY RUN' : 'LIVE'}`);
if (config.testMode) {
console.log(`🧪 Test mode: Processing first ${config.maxPosts} posts only`);
}
console.log('');
// Get all MDX files
let files;
try {
const allFiles = await fs.readdir(config.contentDir);
files = allFiles.filter((f) => f.endsWith('.mdx') || f.endsWith('.md'));
if (config.maxPosts) {
files = files.slice(0, config.maxPosts);
}
} catch (error) {
console.error(`❌ Failed to read directory: ${config.contentDir}`);
console.error(` ${error.message}`);
process.exit(1);
}
console.log(`📝 Found ${files.length} files to process\n`);
// Process each file
const results = {
created: 0,
skipped: 0,
failed: 0,
dryRun: 0,
};
for (const file of files) {
const filePath = path.join(config.contentDir, file);
console.log(`Processing: ${file}`);
try {
const postData = await processMdxFile(filePath);
const result = await createPost(postData);
results[result.status === 'dry-run' ? 'dryRun' : result.status]++;
} catch (error) {
console.error(` ✗ Error processing file: ${error.message}`);
results.failed++;
}
}
// Print summary
console.log('\n📊 Migration Complete\n');
if (config.dryRun) {
console.log(` Would create: ${results.dryRun} posts`);
} else {
console.log(` ✓ Created: ${results.created} posts`);
console.log(` ⊙ Skipped: ${results.skipped} posts (already exist)`);
}
if (results.failed > 0) {
console.log(` ✗ Failed: ${results.failed} posts`);
}
if (!config.dryRun && results.created > 0) {
console.log('\n📌 Next Steps:');
console.log(
' 1. Upload image files to Ghost Admin > Settings > Labs > Import content',
);
console.log(
' 2. Or upload directly to your S3 bucket if using S3 storage',
);
console.log(' 3. Verify posts in Ghost Admin > Posts');
}
}
// Run migration
migrate().catch((error) => {
console.error('\n❌ Migration failed:', error.message);
process.exit(1);
});
Required Dependencies
Create a package.json file:
{
"name": "mdx-to-ghost-migration",
"version": "1.0.0",
"description": "Migrate MDX content to Ghost CMS",
"main": "migrate.js",
"scripts": {
"migrate": "node migrate.js",
"test": "node migrate.js --test --dry-run"
},
"dependencies": {
"@tryghost/admin-api": "^1.13.0",
"gray-matter": "^4.0.3",
"dotenv": "^16.0.3"
}
}
Setting Up and Running the Migration
First, install the dependencies:
pnpm install @tryghost/admin-api gray-matter dotenv
Get your Ghost Admin API key by logging into Ghost Admin, navigating to Settings → Integrations, and creating a new custom integration. Copy the Admin API Key - it will look like a long string with a colon in the middle.
Create a .env file with your configuration:
GHOST_URL=https://your-ghost-site.com
GHOST_ADMIN_KEY=your-admin-api-key-here
MDX_CONTENT_DIR=/path/to/your/mdx/files
MDX_IMAGES_DIR=/path/to/your/images
GHOST_AUTHOR_EMAIL=your-email@example.com
Run the migration in test mode first to see what would be created:
node migrate.js --test --dry-run
This processes the first two posts without actually creating them. When you're ready, run the full migration:
node migrate.js
The script processes each MDX file, extracts the frontmatter metadata, converts the content to Ghost's Mobiledoc format, and creates posts via the Ghost Admin API. It skips posts that already exist (based on slug) so it's safe to run multiple times.
Handling Images with S3 and CloudFront
When running Ghost on AWS, images are stored in S3 but served through CloudFront. The best approach is to upload images directly to S3 and use CloudFront URLs from the start. This avoids the need for database updates later.
Upload your images to S3 in the Ghost directory structure:
s3://your-bucket/content/images/2024/10/image.jpg
Then update your migration script to use CloudFront URLs:
// In processImages function
const cloudFrontBase = 'https://images.yourdomain.com';
const newImagePath = `${cloudFrontBase}/content/images/${new Date().getFullYear()}/${String(
new Date().getMonth() + 1,
).padStart(2, '0')}/${imageName}`;
Database Updates for URL Changes
If you need to update image URLs after migration (for example, when moving from staging to production), use these SQL commands:
-- Update feature images
UPDATE posts
SET feature_image = REPLACE(
feature_image,
'https://old-domain.com/',
'https://new-domain.com/'
)
WHERE feature_image LIKE '%old-domain%';
-- Update mobiledoc content (for inline images)
UPDATE posts
SET mobiledoc = REPLACE(
mobiledoc,
'old-domain.com',
'new-domain.com'
)
WHERE mobiledoc LIKE '%old-domain%';
-- Update post metadata
UPDATE posts_meta
SET og_image = REPLACE(
og_image,
'https://old-domain.com/',
'https://new-domain.com/'
)
WHERE og_image LIKE '%old-domain%';
Important Considerations
The success of this migration depends on preserving the Mobiledoc format. If you accidentally import as HTML or modify the Mobiledoc structure, your content becomes non-editable in Ghost's editor. Always test with a small subset of posts first.
The script handles common frontmatter variations like date versus publishedAt and description versus excerpt. If your MDX files use different field names, modify the processMdxFile function accordingly.
Ghost's Admin API has rate limits. For large migrations with hundreds of posts, you might need to add delays between API calls. The script runs sequentially, which usually avoids rate limit issues for typical blog migrations.
What Happens
When you run the migration, the script reads each MDX file from your content directory and extracts the frontmatter using gray-matter. Our MDX files were already clean Markdown without JSX components or imports to remove, which simplified the content processing.
The script creates the specific Mobiledoc structure that Ghost requires for editable content. Without the correct Mobiledoc format, your content won't be editable in Ghost. The script then calls Ghost's Admin API to create each post, checking first to avoid duplicates.
Running the script itself only takes a few minutes for the actual post creation. However, the complete migration process including fixing image URLs, uploading images to S3, and running SQL updates can take considerably longer depending on how many images you have and which approach you take.
After Migration
Once your posts are in Ghost, verify that your content is editable by opening a few posts in the Ghost editor. You should see your Markdown content, not HTML blocks. Check that metadata like publish dates, tags, and excerpts mapped correctly.
The most important post-migration task is fixing image URLs if you're using S3 and CloudFront. Run the SQL updates after migration to convert S3 URLs to CloudFront URLs.
Ghost's editor might format things slightly differently than your original Markdown. Code blocks, lists, and other Markdown elements should work as expected, but review a few posts to ensure everything renders correctly.
This migration approach preserves Markdown editability while moving to Ghost's CMS. The key is understanding Ghost's Mobiledoc format, handling image URLs correctly from the start, and being prepared to run SQL updates when needed.