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:

  1. Connect to the WordPress database and fetch all published posts
  2. Convert WordPress blocks to MDX format
  3. Extract and download all media files
  4. Generate appropriate frontmatter for each article
  5. 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:

  1. Create a new directory under src/app/articles/[year]/[month]/[slug]/
  2. Add a page.mdx file with frontmatter and content
  3. Include any images or assets in the same directory
  4. 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:

  1. Builds the Next.js application
  2. Connects to my server via SFTP (I'm planning to migrate to SSH soon)
  3. Deploys the built application
  4. Restarts the Node.js process
GitHub workflow

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:

  1. Performance: Pages load nearly instantly, with excellent Lighthouse scores
  2. Writing Experience: Writing in MDX is distraction-free and developer-friendly
  3. Version Control: All content is version-controlled in Git
  4. Flexibility: I can create any custom component I need
  5. Deployment: Pushing to GitHub automatically updates my blog
  6. SEO: Full control over metadata, structured data, and URL structure
  7. Dark/light mode: Super easy with Tailwind CSS even for complex components like charts
  8. AI helpers: Since I write posts in MDX, I can use AI to help me write the post.
Pagespeed scores

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.