6 Years of WordPress → Self-Hosted: Zero Annual Fees, Total Freedom, Full Prompts Included

A complete migration record from deciding not to renew hosting to DNS cutover: rebuilding a bilingual blog with Astro + Vercel, moving images to Cloudinary, replacing comments with Supabase, and building a publishing pipeline along the way.

Took three months — but honestly, the pure migration part only takes a week. I’ve left all the prompts right here in this post.

This is a full record of how I migrated ijuhsu.com from WordPress to Astro. I started in April 2026 whenever I had free time, and the DNS cutover completed on July 4th — three months in total including a full redesign. If you’re just migrating without redesigning, you can wrap it up in a week.

260 posts, bilingual (Traditional Chinese + English), a custom comment system, 800+ images — if you’re looking to break free from managed hosting and want more control, I hope this saves you some headaches ;)

Skill level required: Basic terminal familiarity (cd, npm run) is enough. No coding required — Claude Code handles all the code, you just need to read it and tell if something looks wrong.

ijuhsu.com game-style homepage

The freedom to turn your homepage into a side-scrolling game!


Why migrate?

ijuhsu — Mostly Harmless launched in 2020 — huge thanks to Selena for teaching me how to set up WordPress back then :D
I was on A2 Hosting shared hosting, a 3-year plan at $503 USD expiring May 2027.
But I kept running into problems…

  1. Cost — $503 was justifiable at the time, but in the AI era, I started questioning whether managed hosting was even necessary
  2. Plugin bloat — I had 14 plugins: SEO, multilingual, image compression, backups, firewall, social posting… each with its own settings page. Conflicts popped up constantly Q_Q. Wanting to tweak a layout meant first figuring out what OceanWP was overriding in CSS…
  3. Freedom — WordPress always has walls you can’t get past

Some recent side projects where I built sites with AI made me realize it wasn’t as hard as I’d thought.
I post infrequently (:P), so my costs would be extremely low — which made self-hosting feel like an obvious move.
After talking it through with Claude Code, I landed on Astro — static output + Vercel free tier, basically zero hosting cost.
It solved every problem I had, so I started chipping away at it on weekends!


The Stack

Here’s what I settled on after working through the architecture with Claude Code:


Migration Phases

Phase 0: Build the Foundation

First step: get Claude Code to scaffold the Astro skeleton.

I spent some time on the design side — since my content spans both casual and professional topics, I wanted two distinct Design Systems that would feel clearly different.
I also wanted to preserve some of my illustration style, so I kept things relatively simple for the initial launch.

Casual style design

Kept the typography simple and clean

Pro journal-style design

The grid gives it an academic or professional journal feel :P

On top of the dual-style, dual-language setup (many of my game guide readers are overseas XD), I also needed to get the URL structure right —
Chinese with no prefix, English under /en/. This is the decision with the biggest SEO impact, so it had to be figured out upfront.

Everything gets verified with npm after Claude Code builds it, pushed to GitHub, and Vercel auto-deploys.

Pro tip: For planning, use the highest-tier model (I used Fable or Opus), then hand off execution to Sonnet — it goes much faster that way!

Claude Code Prompt (Phase 0):

I want to migrate my WordPress blog to Astro. Please help me:
1. Scaffold an Astro 5 project with MDX content collection support
2. Set up bilingual routing: Chinese with no prefix (/posts/slug), English under /en/posts/slug
3. Layout: (your call)
4. Deployment target: Vercel static output

Give me an architecture plan and folder structure first — I'll confirm before you write any code.

Phase 1: Migrate Images

I expected this to be complicated. It wasn’t — 845 images, and Claude Code wrote a migrate-images.mjs script that handled it:

  1. WP REST API to batch-fetch the media library
  2. Upload directly from URLs to Cloudinary (no local download needed)
  3. Output a WP URL → Cloudinary public ID mapping file

Claude Code Prompt (Phase 1):

Write migrate-images.mjs to batch-migrate a WordPress media library to Cloudinary:
1. Paginate through all images via WP REST API (/wp-json/wp/v2/media?per_page=100&page=X)
2. Upload each image directly from its original URL to Cloudinary (no local download)
3. Output a JSON mapping: { wpUrl: cloudinaryPublicId }

Cloudinary naming: /{slug}-{nn} (nn is zero-padded sequential number)
WP site URL: https://my-site.com
Cloudinary credentials from env vars (CLOUDINARY_CLOUD_NAME / API_KEY / API_SECRET)

Took an hour or two to run. 845 images total, 842 uploaded successfully, 3 returned 404 (orphaned files on the WP side QQ). Great success rate.

Phase 2: Convert to MDX

Claude Code wrote migrate-posts.mjs to convert six years of 147 WP posts (the final 260 includes English translations and new posts written in Astro):

  1. WP REST API to fetch HTML post content
  2. HTML → Markdown via turndown
  3. Swap image URLs → Cloudinary format
  4. Output MDX files with complete frontmatter

Claude Code Prompt (Phase 2):

Write migrate-posts.mjs to batch-convert WordPress posts to Astro MDX:
1. Paginate through all posts via WP REST API
2. Convert HTML content to Markdown using turndown
3. Replace image URLs with Cloudinary format using the mapping from migrate-images
4. Output MDX with frontmatter: title, description, pubDate, lang, slug, category

Clean up: WP shortcodes, [caption] tags, TOC plugin HTML, Elementor markup
Output path: src/content/posts/zh/

The hardest part was cleaning up plugin residue in the WP HTML: shortcodes, TOC blocks, Elementor page builder markup — each one needed its own regex to strip out.
After the script ran, I still needed manual review for categorization — 50 posts ended up getting recategorized.
There’s still some legacy cleanup to do, but the majority of posts are in good shape.

Phase 3: Translations

The English content from WP was scattered — some in the zh folder, some translated, some not.
Got everything organized and filled in 126 English translations using the translate-zh-to-en skill; each still needed manual review — English versions are AI-assisted translations, let me know in the comments if you spot any issues.
Worth noting: if you have special translation rules (like “Lottery” for my dog’s name), make sure to bake those into your skills too.

Claude Code Prompt (Phase 3):

Translate src/content/posts/zh/my-post.mdx to English MDX, save to src/content/posts/en/.
- translationKey must match the zh version exactly
- Keep the original tone — don't over-formalize
- Don't translate proper nouns or tool names (Astro, Cloudinary, etc.)
- List any special translation rules upfront (e.g. "樂透" → "Lottery")

Phase 4: Migrate Comments

326 WP comments total. Claude Code wrote migrate-wp-comments.mjs for a two-pass import:
first insert top-level comments, then insert replies (preserving parent_id nesting).
There are pre-built plugins for this, but some require a GitHub account (many of my readers don’t have one) and others cost money.
After some research I went with Supabase as the backend, plus Cloudflare Turnstile for bot protection — cuts out most spam.
I also added Telegram notifications for comment review and replies, so I never have to touch an admin panel!

Supabase comment system

Clean and minimal comment section that matches the page style!

Telegram Bot comment moderation

New comments get moderated right from Telegram

Claude Code Prompt (Phase 4):

Write migrate-wp-comments.mjs to import WordPress comments into Supabase.
Table schema: id, post_slug, parent_id, author_name, content, created_at, approved

Process:
1. Fetch all approved comments via WP REST API (/wp-json/wp/v2/comments?per_page=100&status=approved)
2. Two-pass insert: first top-level comments (parent=0), get new IDs, then insert replies (matching parent_id)
3. Parse post_slug from the comment's link field

Supabase connection via env vars (SUPABASE_URL, SUPABASE_SERVICE_KEY)

Phase 5: SEO / Redirects

This step is easy to skip but the most important. All old WP URLs (/archives/12345, /?p=xxx) need 301s to the new URLs — otherwise Google has to re-crawl everything from scratch and you lose accumulated rankings ;_;

Claude Code programmatically generated 262 redirect rules from the post list, written directly into astro.config.mjs. Also added JSON-LD schema, hreflang cross-referencing, a custom 404, and llms.txt.
P.s. I built my own 404 page too :D

Claude Code Prompt (Phase 5):

My WP URL formats include: (list yours here)

New URL format: /posts/{slug} (Chinese), /en/posts/{slug} (English) (adjust to match yours)

Read the frontmatter from src/content/posts/zh/*.mdx and generate a redirects array
for astro.config.mjs, mapping all old URL formats to the new slug with 301s.
Also add:
- hreflang cross-referencing (zh ↔ en)
- JSON-LD Article schema
- sitemap (@astrojs/sitemap)
- Custom 404 page

The Move! DNS Cutover

Once everything was verified and all the images and content were in place, I cut over on 2026/07/04!
Since I was already on Cloudflare, I deleted the A2 Hosting A record, added a CNAME pointing to Vercel, and set it to DNS only so Vercel could auto-provision SSL.

Two things to update after switching:

  1. Cloudflare Turnstile allowed domains
  2. Telegram webhook URL (https://api.telegram.org/bot{TOKEN}/setWebhook)

And that’s it — live! :D

Post-Launch Surprise: Google-Indexed Old URLs

I thought setting up redirects beforehand was enough — but analytics still showed 404s after launch. The reason: Google had old WordPress URLs indexed that weren’t just “old post URLs.”

A few unexpected gotchas:

1. WP auto-appends -2 to English slugs WordPress treats zh and en posts as separate articles. When both have the same slug, the second one created gets a -2 appended. So Google’s indexed URLs for English posts look like:

/en/n8n-learningjapanese-2/   ← not the actual slug, WP auto-added this
/en/phd2uxr-2/

There were 130+ posts with this pattern — all needing individual redirects.

2. WP special pages and taxonomy URLs WP tag pages use /tag/:tag/, but my Astro site uses /tags/:tag/ (with an s); the contact page /contact/ doesn’t exist in Astro — needs to redirect to /about/; old category pages /category/:cat/ need to redirect to /:cat/.

How to find what’s still broken Google Search Console’s “Not found (404)” report is the first place to look. You can also search site:yourdomain.com in Google to see all indexed pages and check whether they still open correctly.

My vercel.json ended up going from a planned 262 rules to 844 after plugging all the gaps. Once 301 redirects are in place, Google’s crawlers will eventually transfer the old rankings to the new URLs.


Current Architecture

ijuhsu.com (Vercel, static + serverless API)
├── Content: ~260 MDX posts (zh + en)
├── Images: Cloudinary (auto WebP + resize)
├── Comments: Supabase + Cloudflare Turnstile (bot protection)
├── Notifications: Telegram Bot
└── Search: Fuse.js client-side fuzzy search

Publishing now has two paths: short posts go via Telegram → n8n → GitHub Actions auto-commits a draft MDX (see this post for how that pipeline works — I gave it edit access!);

Telegram publishing flow

Quick posts straight from Telegram!

Longer posts still go through the Claude Code pipeline (draft → images → fact-check → translate → publish).


How Much Faster Is It?

PageSpeed was always a pain point on WordPress — tried everything, nothing really moved the needle. After the migration I ran PageSpeed Insights immediately:

MetricWordPress eraAstro + Vercel
PageSpeed (desktop)Can’t retest (site is down)99 / 100
PageSpeed Mobile LighthouseCouldn’t get it up (est. 30–50)55 / 100
LCP (desktop)0.9s
FCP (desktop)0.7s

Mobile 55 isn’t ideal, but the context is “slow 4G throttling” simulation (1.6 Mbps), and CJK fonts (Noto TC) are inherently heavier. Claude Code noted that Taiwanese users on actual WiFi / 4G are closer to the desktop experience, so I left it there. The WP era on A2 Hosting shared hosting was worse — PHP processing time + 14 plugins’ worth of JS was a mess.

PageSpeed Insights desktop score: 99

Used to hover around 40–50. Seeing 99 was genuinely moving!

A few optimizations after the migration:

All of these made a real difference :D


Was It Worth It?

AspectWordPress + A2 HostingAstro + Vercel
Hosting cost$503 USD / 3 yearsFree (Vercel Hobby)
Plugin dependencies14 (SEO, backups, firewall…)0
Making changesWP admin + theme CSS overridesEdit .astro components directly
DeploymentFTP / WP admin manual updatesgit push → auto-deploy
Post storageMySQL databaseMDX plain text, git version control
Image handlingWP plugins (Smush etc.)Cloudinary auto WebP + resize
Comment moderationWP admin dashboardOne-tap approval in Telegram
PageSpeed (desktop)Est. 30–50 (hard to improve)99 / 100
PageSpeed (mobile)Worse55 / 100
CustomizationConstrained by theme and pluginsComplete freedom

Huge hosting savings, change the design whenever I want, no more plugin version conflicts.
Writing posts is less visual than before since it’s plain text files in git — but if you’re comfortable with Markdown it’s actually faster than the WP editor.
The cost was time — about three months, but mostly because I only worked on it when I had free time (my plan doesn’t expire until next year, and I was juggling other projects like LinguaPass XD).
I only used Claude Code on Pro tier, and whenever I hit my rate limit I’d just go do something else XD

If you only have 20–30 posts, just stay on WordPress.com — it’s honestly fine for that.
But at 260 posts, bilingual, wanting full control? Totally worth it. And now I can put Lottery everywhere. Perfect XD.


WP → Astro Migration Checklist

Follow this order and you won’t miss anything:

Before you start

Phase 0: Build the foundation

Phase 1: Migrate images

Phase 2: Convert posts

Phase 3: Translations

Phase 4: Comments

Phase 5: SEO / Redirects

DNS cutover


Thinking about migrating your WordPress site? Drop a comment with any questions, or find me via the about page on Facebook / LinkedIn 😊

p.s. Can anyone find the easter egg hidden on the site? :p

Homepage mini game — Iju & Lottery

What’s the highest score you can get?

Comments

  • Loading…