How I migrated my WordPress blog to Next.js and MDX
In my previous post, I explained why I decided to move away from WordPress after more than a decade. Today, I'm diving into the how, the technical details of migrating my content and building a new blog system with Next.js 14, React 18, Tailwind 4.1, TypeScript 5.3, and MDX 2.1.
This is a practical guide for developers interested in making a similar transition. I'll cover content migration, the new architecture, custom components, and the automated publishing workflow I've set up.
The Migration Process: Extracting Content from WordPress
The first challenge was extracting over a decade of blog posts from WordPress and converting them to MDX format. I developed two different approaches for this migration:
Method 1: Direct Database Access
I started by writing a Python script to:
- Connect to the WordPress database and fetch all published posts
- Convert WordPress blocks to MDX format
- Extract and download all media files
- Generate appropriate frontmatter for each article
- Create the proper directory structure for Next.js routing
Here's a simplified snippet of the database migration script:
import mysql.connector
import re
import os
import requests
from bs4 import BeautifulSoup
# Connect to WordPress database
conn = mysql.connector.connect(
host="localhost",
user="db_user",
password="db_password",
database="wordpress_db"
)
cursor = conn.cursor(dictionary=True)
# Fetch published posts
cursor.execute("""
SELECT ID, post_title, post_content, post_date
FROM wp_posts
WHERE post_status = 'publish' AND post_type = 'post'
""")
posts = cursor.fetchall()
for post in posts:
# Create slug from title
slug = re.sub(r'[^a-z0-9]+', '-', post['post_title'].lower())
# Extract year and month for directory structure
year = post['post_date'].strftime('%Y')
month = post['post_date'].strftime('%m')
# Create directory path
dir_path = f"src/app/articles/{year}/{month}/{slug}"
os.makedirs(dir_path, exist_ok=True)
# Convert content to MDX
mdx_content = convert_to_mdx(post['post_content'])
# Create frontmatter
frontmatter = f"""export const article = {{
author: 'Remi Corson',
date: '{post['post_date'].strftime('%Y-%m-%d')}',
title: '{post['post_title']}',
description: '{get_excerpt(post['post_content'])}',
}}"""
# Write MDX file
with open(f"{dir_path}/page.mdx", "w") as f:
f.write("import { ArticleLayout } from '@/components/ArticleLayout'\n\n")
f.write(frontmatter + "\n\n")
f.write("export const metadata = {\n title: article.title,\n description: article.description,\n}\n\n")
f.write("export default (props) => <ArticleLayout article={article} {...props} />\n\n")
f.write(mdx_content)
Method 2: WordPress REST API
I also tried to use the WordPress REST API:
import requests
import os
import re
import json
import time
from html import unescape
from urllib.parse import urlparse
# WordPress site information
wp_domain = "https://remicorson.com"
wp_api_url = f"{wp_domain}/wp-json/wp/v2"
# Authentication (if needed)
auth = None
# For sites with authentication required:
# auth = ("consumer_key", "consumer_secret")
def fetch_all_posts():
"""Fetch all published posts from WordPress via REST API"""
posts = []
page = 1
per_page = 100
while True:
print(f"Fetching page {page}...")
response = requests.get(
f"{wp_api_url}/posts",
params={
"page": page,
"per_page": per_page,
"status": "publish",
"_embed": 1 # Include featured media and author info
},
auth=auth
)
if response.status_code == 400: # No more pages
break
response.raise_for_status()
page_posts = response.json()
if not page_posts:
break
posts.extend(page_posts)
# Check if we've reached the last page
if len(page_posts) < per_page:
break
page += 1
time.sleep(1) # Be nice to the API ;-)
print(f"Total posts fetched: {len(posts)}")
return posts
def download_media(url, output_dir):
"""Download media file from WordPress"""
if not url:
return None
filename = os.path.basename(urlparse(url).path)
output_path = os.path.join(output_dir, filename)
# Skip if already downloaded
if os.path.exists(output_path):
return filename
response = requests.get(url, stream=True)
if response.status_code == 200:
with open(output_path, 'wb') as f:
for chunk in response.iter_content(1024):
f.write(chunk)
return filename
return None
def convert_content_to_mdx(content, slug, media_dir):
"""Convert WordPress HTML content to MDX format"""
# A more complex implementation would handle Gutenberg blocks here
# This is a simplified version
# Download and replace images
# Replace WordPress embeds with React components
# Convert shortcodes to React components
# Handle code blocks
# For this example, we'll just do a simple cleanup
content = content.replace('<!-- wp:paragraph -->', '')
content = content.replace('<!-- /wp:paragraph -->', '')
# Replace image blocks with Next.js Image components
# This would be much more complex in a real implementation
return content
# Main migration process
posts = fetch_all_posts()
for post in posts:
# Extract post data
post_id = post['id']
title = unescape(post['title']['rendered'])
content = post['content']['rendered']
date = post['date']
slug = post['slug']
# Create directory structure based on post date
date_obj = date.split('T')[0]
year, month, day = date_obj.split('-')
dir_path = f"src/app/articles/{year}/{month}/{slug}"
media_dir = f"{dir_path}"
os.makedirs(dir_path, exist_ok=True)
os.makedirs(media_dir, exist_ok=True)
# Convert content to MDX
mdx_content = convert_content_to_mdx(content, slug, media_dir)
# Handle featured image if available
featured_image_path = None
if '_embedded' in post and 'wp:featuredmedia' in post['_embedded']:
try:
featured_media = post['_embedded']['wp:featuredmedia'][0]
if 'source_url' in featured_media:
image_url = featured_media['source_url']
featured_image_filename = download_media(image_url, media_dir)
if featured_image_filename:
featured_image_path = featured_image_filename
except (IndexError, KeyError):
pass
# Create frontmatter
frontmatter = f"""export const article = {{
author: 'Remi Corson',
date: '{date_obj}',
title: '{title.replace("'", "\\'")}',
description: '{post['excerpt']['rendered'].strip("<p>").strip("</p>").replace("'", "\\'")}',
}}"""
# Add featured image import if available
if featured_image_path:
frontmatter = f"import image1 from './{featured_image_path}'\n\n" + frontmatter
# Write MDX file
with open(f"{dir_path}/page.mdx", "w") as f:
f.write("import { ArticleLayout } from '@/components/ArticleLayout'\n\n")
if featured_image_path:
f.write(frontmatter + "\n\n")
else:
f.write(frontmatter + "\n\n")
f.write("export const metadata = {\n title: article.title,\n description: article.description,\n}\n\n")
f.write("export default (props) => <ArticleLayout article={article} {...props} />\n\n")
f.write(mdx_content)
print(f"Processed: {title}")
time.sleep(0.5) # Be nice to the API
The convert_to_mdx() function was the most complex part, handling WordPress blocks, shortcodes, and embedded content. I had to write custom parsers for:
- Gutenberg blocks → MDX format
- WordPress shortcodes → React components
- Image blocks → Next.js Image components with proper imports
- Code blocks → Properly formatted code snippets with syntax highlighting
- Custom content types → Specialized React components
The New Architecture: Next.js App Router + MDX
My new blog is built on Next.js 14 using the App Router. Here's the simplified structure:
src/
├── app/
│ ├── articles/
│ │ ├── [year]/
│ │ │ ├── [month]/
│ │ │ │ ├── [slug]/
│ │ │ │ │ ├── page.mdx
│ │ │ │ │ └── images...
│ │ │ │ ├── layout.jsx
│ │ │ │ └── page.jsx
│ ├── components/
│ │ ├── ArticleLayout.jsx
│ │ ├── CodeBlock.jsx
│ │ ├── Container.jsx
│ │ └── ...
│ ├── lib/
│ │ ├── articles.js
│ │ └── formatDate.js
│ └── ...
The content lives in MDX files inside the /articles/[year]/[month]/[slug]/ directories. Next.js automatically handles the routing based on this file structure, a significant improvement over WordPress's URL management.
Custom React Components for Enhanced Content
One major advantage of MDX is the ability to include React components directly in markdown. I've created several custom components:
// CodeBlock.jsx
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { nightOwl } from 'react-syntax-highlighter/dist/cjs/styles/prism';
export function CodeBlock({ children, language }) {
return (
<div className="my-6 rounded-lg overflow-hidden">
<SyntaxHighlighter
language={language || 'javascript'}
style={nightOwl}
className="text-sm"
>
{children}
</SyntaxHighlighter>
</div>
);
}
I've also created specialized components for:
- Demo frames for live code examples
- Animated charts for data visualization
- Interactive diagrams
- Expandable sections for detailed explanations
- Custom callouts for tips, warnings, and notes
These components make my MDX files cleaner while allowing for rich, interactive content that would have required complex plugins in WordPress.
Content Management and Publishing Workflow
The beauty of this setup is in its simplicity. To publish a new article:
- Create a new directory under
src/app/articles/[year]/[month]/[slug]/ - Add a
page.mdxfile with frontmatter and content - Include any images or assets in the same directory
- Push to GitHub
Automated Deployment with GitHub Actions
The final piece is automated deployment. Since I host on O2switch (not Vercel, but I have to admit I tested it on Vercel and the deployment was flawless), I set up a GitHub Action workflow that:
- Builds the Next.js application
- Connects to my server via SFTP (I'm planning to migrate to SSH soon)
- Deploys the built application
- Restarts the Node.js process
Here's a simplified version of my GitHub Action:
on:
push:
branches: [master]
name: 🚀 Deploy website on master push
jobs:
FTP-Deploy-Action:
name: 🎉 Deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
env:
NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}
NODE_ENV: production
- name: 📂 Sync files to FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: './'
exclude: |
**/.git*
**/.git*/**
**/node_modules/**
**/src/**
**/.next/cache/**
**/.next/trace
**/.next/server/chunks/app-paths-manifest.json
**/.next/server/chunks/middleware-manifest.json
**/.next/server/middleware*
**/.next/server/pages/**
**/.next/cache/webpack/**
**/.next/analyze/**
**/.vscode/**
**/.DS_Store
**/*.log
server-dir: ${{ secrets.FTP_SERVER_DIR }}
protocol: ftp
port: 21
- name: Trigger application restart
run: |
curl "https://remicorson.com/${{ secrets.RESTART_NODE_PHP_FILE }}?token=${{ secrets.RESTART_TOKEN }}"
env:
RESTART_TOKEN: ${{ secrets.RESTART_TOKEN }}
On the server side, I have a small Node.js application to serve the static files and handle server-side rendering when needed.
Scheduling a new post
Scheduling a new post is extremely easy. All I have to do is using a future date in the post details. For example, if I want to publish a post on May 10, 2025, I just need to set the date to 2025-05-10 in the frontmatter. And that's it!
Advantages of the New System
After using this setup for a few weeks, the benefits are clear:
- Performance: Pages load nearly instantly, with excellent Lighthouse scores
- Writing Experience: Writing in MDX is distraction-free and developer-friendly
- Version Control: All content is version-controlled in Git
- Flexibility: I can create any custom component I need
- Deployment: Pushing to GitHub automatically updates my blog
- SEO: Full control over metadata, structured data, and URL structure
- Dark/light mode: Super easy with Tailwind CSS even for complex components like charts
- AI helpers: Since I write posts in MDX, I can use AI to help me write the post.
Challenges and Lessons Learned
The transition wasn't without challenges:
- Content Migration: Some complex WordPress blocks required manual conversion
- Image Optimization: Setting up proper image handling with Next.js took time
- Server Configuration: Getting Node.js running smoothly on shared hosting required some tweaking
- RSS and Sitemap: Had to implement these manually as they don't come out of the box
Final Thoughts
Moving from WordPress to Next.js + MDX was a significant undertaking, but the resulting workflow and performance improvements have been worth it. For developers who value control, speed, and a Git-based workflow, this approach eliminates many frustrations of traditional CMS platforms. No need to bother with FSE themes, Gutenberg blocks, or any other WordPress-specific features.
So, this approach is not for everyone. If you don't want to deal with the technical challenges of migrating from WordPress to Next.js, this is not for you. But if you are a developer who wants to have full control over your content and your blog, this might be the perfect solution, or not!
The only downside I found is the time to run the deploy script. It takes about 2 to 4 minutes to deploy the blog. But I'm sure I'll learn a lot of things in the next few months, and I'll be able to improve the blog. And I'll share my learnings in future posts. But as of today, I'm very happy with the result.