To migrate a WordPress site to Next.js, you export your content as XML, convert posts to MDX files using a Node.js script, upload images to a CDN like Cloudflare R2, and deploy the Next.js app to a VPS or hosting platform. The entire process took about 2 weeks of evening work for a site with 22 archived posts, 147 comments, and over 300 images.
After ten years on WordPress hosted at GoDaddy, I finally made the jump. This site is now running on Next.js 15, deployed on a Hetzner CPX21 VPS at $9.99/month, with images served through Cloudflare R2's free egress tier. Here's what I learned.
Why leave WordPress?
WordPress served me well for a long time. But as a developer working with Next.js professionally at Pantone across 10 multilingual sites, maintaining a PHP site on the side felt increasingly like unnecessary context switching. I wanted three things:
- A codebase I actually enjoy working in — TypeScript, React components, Tailwind CSS
- Full control over performance — no plugin bloat, no database queries for static content
- A learning project — setting up my own VPS, nginx, SSL, and CI/CD pipeline from scratch
What does the new architecture look like?
The stack is deliberately simple. Next.js 15 with App Router handles routing and rendering. MDX files in a /content directory replace the WordPress database — no CMS, no admin panel. Tailwind CSS with a custom design system replaces the theme layer. next-intl provides internationalization across 6 languages. Cloudflare R2 hosts all images with zero egress costs. A Hetzner VPS running nginx and PM2 serves the app, with GitHub Actions deploying on every push to main.
Total infrastructure cost: $9.99/month for the VPS. Everything else is free tier.
How did the content migration work?
The Bonnie Café motorcycle blog had 22 posts with hundreds of images and 147 comments across all posts. The migration involved 4 steps:
- Exported WordPress XML via Tools → Export (took about 30 seconds)
- Wrote a 288-line Node.js script to parse the XML with xml2js, converting each post to MDX with frontmatter including title, date, slug, category, excerpt, and comments as a YAML array
- Downloaded the 1.2GB
wp-content/uploadsdirectory via SFTP and uploaded to R2 usingrclone sync(about 8 minutes) - Ran a find-and-replace across all MDX files to swap WordPress image URLs to R2 paths
The comments render as static display-only cards at the bottom of each post. No new comments — the blog is archived. This eliminated the need for a database entirely.
What would I do differently?
If I were starting over, I'd set up the R2 bucket and image pipeline before writing any code. Migrating images after the fact meant URL replacement work across 22 files that could have been avoided with the right paths from day one.
I'd also look at Contentlayer or Velite instead of hand-rolling the MDX compilation. Parsing frontmatter manually with gray-matter works, but those tools give you type-safe content with zero configuration.
What are the results?
The site loads in under 800ms on a cold start. Lighthouse scores: 98 Performance, 100 Accessibility, 100 Best Practices, 100 SEO. Build time is 12 seconds. Deploy-on-push means a change goes live in about 45 seconds from commit to production.
Total migration time was roughly 40 hours spread across 2 weeks of evenings and weekends. Worth every hour.