Skip to content
Advertisement

WordPress to Astro: 4,000 Posts, 0 Plugins

Amit YadavAmit Yadav···26 min read
Small server rack with networking gear in a home office

TheTechBasket ran on WordPress from 2019. By 2026 that meant 39 plugins installed, 32 of them active, a PHP and MySQL server behind every request, and a Cloudflare cache-hit ratio that had collapsed to 4.2%. So I rebuilt the whole site as static Astro: same URLs, same search-traffic protection, a completely different stack underneath, and about 4,000 posts moved across. This is what broke, what replaced it, and the exact checklist I used.

Why WordPress had to go

Four problems, all structural, none of them fixable with another plugin.

1. Caching WordPress properly was a full-time job

Cloudflare's cache-hit ratio had fallen from 65.7% (Sep 2025) to 4.2% (Aug 2026). Every one of the top 151 pages I checked came back cf-cache-status: DYNAMIC, with no Cache-Control header at all. Fixing that is harder than it sounds on a WordPress-style site:

  • Every plugin update or site change can break the styling. Cache plugins combine, minify and defer CSS and JS, and one update can leave the whole site unstyled until you find the cause.
  • Logged-in and anonymous visitors need different responses. Keeping both correct in the server cache is fragile. Get it wrong and someone sees another state's page.
  • Caching HTML means purging HTML. When you must purge everything, the CSS, JS and images go out of the Cloudflare cache with it, and the origin takes the full load while it refills.
  • Error pages must not be cached, but must be handled. A cached 404 or 500 is worse than a slow page, so the rules need exceptions that someone has to maintain.
  • Bots do not care about your cache. In August 2026 a flood of 19,000 threats and 6x normal request volume landed straight on PHP. Scrapers and AI crawlers that are not blocked keep walking every page, and mass 404 requests for URLs that never existed make bots dig through the whole site, each miss going to the origin.

Bandwidth is also no longer cheap. In the same month a sister site of mine on Netlify took a bot attack of 718 million requests in 24 hours, mostly GET requests for nonexistent URLs from a very wide botnet that ran JavaScript like real browsers. 15.5 million of them reached the origin, the month's bandwidth hit 246 GB against a 100 GB allowance, and the only thing that stopped the bill was taking the site offline. A site that is not cached and not static pays for every one of those hits.

2. Core Web Vitals were bad exactly where it mattered

Our gaming content, mostly Roblox codes and the biggest slice of the site's traffic, scored mobile performance 25 to 66, LCP up to 7.3s, CLS up to 0.32, and TBT up to 4,520ms. Accessibility was pinned at 83 on every article: one bug in the shared layout, repeated 4,000 times.

3. Page weight was absurd for what the site does

Median HTML was 220KB with 11 external scripts per page, on a site that is mostly a code list and a paragraph of text. Bing's crawler recorded the top article at 219KB. Traffic reflected all of it: pageviews fell from 107,000/month (Sep 2025) to 41,000/month (Aug 2026).

4. Security and maintenance were a permanent tax

Every part of a WordPress install is something to patch: core, the theme, and each of the 32 plugins ships its own vulnerability stream and its own update. Skip one and you own the risk. Someone still has to keep the server online, the PHP version current, and the database backed up, forever. Static removes the whole category instead of managing it.

What one post actually cost

The old stack was WordPress, PHP, a MySQL database, Redis object cache, and 32 plugins. Here is the same Roblox codes post, measured before and after:

Per single Roblox codes post WordPress Astro
HTML document220 KB208 KB
HTML over the wire (gzip)63 KB35 KB
External JavaScript files116 (3 first-party, under 7 KB)
External CSS files71 (deferred)
Inline JavaScript in the HTML110 KB12 KB
Inline CSS in the HTML37 KB130 KB (critical, first paint)
Runtime dependencies32 plugins, PHP + MySQL0
Cache headernone (served DYNAMIC)edge cache, 100% hit

The JavaScript load dropped from 110 KB of inline script to 12 KB, and the request count fell with it. The one number that got worse is the inlined critical CSS, and that is the mobile LCP regression written up below. Fewer moving parts, no PHP, no database, no plugins: that is the trade even where a metric moves the wrong way.

None of that is a WordPress problem specifically. It is what happens to any dynamic site serving the same page structure across thousands of posts for half a decade without re-evaluating what is actually being served and to whom.

What replaced it

Astro, fully static. No React, no client:* islands. shadcn UI primitives ported straight to .astro components. Tailwind v4 tokens for the whole design system, locked before a single page was built, so look-and-feel got decided once, not page by page.

Hosting moved to Cloudflare Pages, built from the GitHub repo on every push to main. Images live in Cloudflare R2 (no egress fees). There is no PHP, no database on the serving path, no cache plugin stack to keep patched, because there is no origin left to protect.

Total first-party client JavaScript on an article page: about 6KB.

The CMS problem

Static content still needs an editing surface. Someone who is not staring at Markdown files in a code editor has to be able to write and update posts. WordPress admin was the one part of the old stack that actually worked. Buying a headless CMS meant paying for, and configuring around, a pile of features this site was never going to use.

Pros

  • Zero setup, working in an afternoon
  • Battle-tested editor and media library
  • Maintenance and security patched by someone else

Cons

  • Paying for page builders, workflow engines, and media transforms a 4-type content site never uses
  • Another vendor dependency on the serving path
  • Content schema shaped by their product, not yours

So I built Boring CMS: one editing surface, sized for exactly what this migration needed, nothing else. No block editor, no plugin marketplace, no page builder. It holds the content, exposes an API, and gets out of the way. The Astro repo pulls from it at build time, content collections are versioned as files, and Boring CMS stays the single place a human edits a post.

Using a CMS is optional, though. Astro is perfectly happy with plain Markdown files in a git repo, and that is how plenty of sites run. Boring CMS is the tooling I personally wanted, and I published it so anyone else can pick it up too: open source (MIT), SQLite, zero runtime dependencies, on npm. Use it, fork it, or ignore it. The migration does not depend on it either way.

A separate post on how Boring CMS is built (the schema, the API, and the MCP surface agents use to write content) is coming next.

What changed: the numbers

Before numbers are the WordPress baseline captured 2026-09-04. The full after-sweep is PageSpeed Insights runs against the live Astro site on 2026-09-13. The five-URL trend further down also includes a 2026-09-18 re-check. Lab scores swing run to run, so treat every number as a band, not a single truth.

Migration at a glance
Cache-hit ratio
4.2%100%
Mobile CLS (worst articles)
0.22-0.32~0.00
Article accessibility
8395
Active plugins
320
WordPress baseline (2026-09-04) vs live Astro site on Cloudflare Pages (2026-09-13).

What improved

Mobile performance: where it improved most
WordPress mobileAstro mobile
Loomian Legacy
25
56
Breaking Pt 2
49
83
Undertale codes
51
90
RNG codes
66
84
Jailbird codes
88
89
How-to article
90
94
Mobile Lighthouse, weakest baseline first. The pages that started lowest gained the most. Before: WordPress 2026-09-04. After: Astro 2026-09-13.

Mobile CLS eliminated on the worst articles. WordPress articles measured CLS 0.22 to 0.32, all failing. Every Astro mobile page now measures ~0.00, and no mobile page fails CLS. Google uses CLS as a ranking signal, so going from "poor" to "good" on the pages that carry traffic is not a nice-to-have.

Accessibility jumped from 83 to 95 on every article. One fix at the layout level, applied everywhere, instead of one bug repeated 4,000 times.

Runtime dependencies went from 32 plugins to 0. No PHP, no MySQL, no WP Rocket, no Imagify, no Redis, no Rank Math. Less to patch, less to break, less to monitor.

Cache-hit ratio went from 4.2% to 100%. Fully static on Cloudflare Pages means every request is served from the edge. The kind of bot flood that hammered PHP in August now costs almost nothing.

What regressed: mobile LCP

Mobile performance score, three points in time
0255075100WordPress (Sep 4)Astro launch (Sep 13)Re-run (Sep 18)About 59Contexto 59Homepage 58Deals 58Undertale codes 58
HomepageDealsAboutContextoUndertale codes
Mobile performance score, three points in time
SeriesWordPress (Sep 4)Astro launch (Sep 13)Re-run (Sep 18)
Homepage905958
Deals905958
About895759
Contexto905359
Undertale codes519058
Five URLs, five days apart. Four were already fast on WordPress and dropped after the move; Undertale codes improved at launch and then slipped back into the same range on the Sep 18 re-run. The pattern points at something page-wide (CSS), not page-specific.

Full mobile results (2026-09-13)

Thresholds: LCP good under 2.5s, poor over 4.0s. CLS good under 0.1. TBT good under 200ms, poor over 600ms. Rows are ordered by performance, worst first.

PageTypePerfLCPCLSTBT
/todays-contexto-answer/Word game538.1s poor0.001 good300ms needs work
/loomian-legacy-codes/Codes568.5s poor0.002 good230ms needs work
/about/Page578.2s poor0.035 good220ms needs work
/zachs-service-station-codes/Codes579.5s poor0 good120ms good
/Home599.0s poor0 good100ms good
/dank-murderer-2-codes/Codes597.5s poor0.003 good160ms good
/deals/Page598.7s poor0 good80ms good
/war-machines-codes/Codes599.1s poor0 good60ms good
/gaming/Hub652.9s needs work0.003 good1090ms poor
/breaking-point-2-codes/Codes832.6s needs work0 good430ms needs work
/rng-battles-codes/Codes843.7s needs work0 good120ms good
/jailbird-codes/Codes891.8s good0.002 good390ms needs work
/undertale-timeline-reset-codes/Codes902.6s needs work0 good230ms needs work
/how-to-access-blocked-websites/How-to941.8s good0.001 good230ms needs work

CLS is good on every mobile page. Accessibility is 95-96 on all 14.

Desktop results (2026-09-13)

Same rows, same order, so the two devices can be read side by side.

PageTypePerfLCPCLSTBT
/todays-contexto-answer/Word game651.1s good0.004 good1080ms poor
/loomian-legacy-codes/Codes881.0s good0.135 needs work170ms good
/about/Page930.9s good0.003 good190ms good
/zachs-service-station-codes/Codes970.7s good0.008 good120ms good
/Home891.1s good0.001 good230ms needs work
/dank-murderer-2-codes/Codes791.1s good0.009 good400ms needs work
/deals/Page970.8s good0.001 good120ms good
/war-machines-codes/Codes930.8s good0.008 good200ms needs work
/gaming/Hub920.9s good0.012 good210ms needs work
/breaking-point-2-codes/Codes721.1s good0.007 good510ms needs work
/rng-battles-codes/Codes971.0s good0.007 good90ms good
/jailbird-codes/Codes960.5s good0.008 good170ms good
/undertale-timeline-reset-codes/Codes921.0s good0.136 needs work110ms good
/how-to-access-blocked-websites/How-to941.0s good0.008 good160ms good

Desktop is the clean story: LCP near 1s on every page, performance 72-97. Two pages (Loomian Legacy, Undertale codes) still measure CLS just above the 0.1 good threshold.

The migration checklist (8 steps)

This is what I wish someone had handed me. Each step has what to capture, what to do, and what to verify. Go in order, and skip nothing.

1. Freeze everything before you touch anything

This is the only chance to get "before" numbers. Do it all on the same day.

  • GA4 export: monthly pageviews, sessions, users, ad revenue for 12 months. By country, device, channel for 3 months. Engagement rate, bounce rate, pages/session.
  • GSC export: monthly clicks/impressions/CTR/position for 16 months. Per-page clicks (all pages, not just top N). Per-query (top 200 + branded split). By country, device.
  • GSC manual exports (no API): Page Indexing report, Core Web Vitals report, Links report, Enhancements (FAQ/HowTo/Breadcrumb counts), manual actions.
  • AdSense: monthly earnings and RPM per domain.
  • Bing Webmaster: monthly stats, crawl stats, pages in index.
  • Lighthouse: mobile + desktop on one URL per page type, 3 runs each, take the median.
  • Full URL inventory from all sitemaps.
  • Per-page SEO snapshot: status code, title, description, canonical, robots, H1, schema types, word count, image count, internal links, script count, HTML bytes, TTFB, cache headers.
  • robots.txt and ads.txt content.
  • Cloudflare/CDN: cache-hit ratio, analytics, zone config.
  • Plugin list with what each one does.
  • Redirect table (Rank Math or whatever manages your redirects).
  • Comments export (REST returned 403 for me; export from the DB or admin instead).
  • Media library size (du -sh on uploads).
  • Hosting cost per month.
  • Screenshots of each page type, mobile and desktop.

1. Decide every URL's fate

Every URL gets one of: keep (same path), 301 (to what), 410 (gone), noindex (exists, not for search).

  • Collect URLs from: sitemaps, GSC (any page with impressions in 16 months), Bing index, the existing redirect table, an internal-links crawl, hot-linked /wp-content/uploads/ images, feeds, pagination, tag/author/date archives, ?replytocom, ?s=, attachment pages, AMP.
  • Write a url-map.csv with one row per URL and its decision.
  • Decide the zero-click posts now. Pruning after the move confounds measurement.
  • Carry all existing redirects as-is. 44 of my top 150 pages were redirects already, almost all pointing at one how-to page.

1. Export content

  • WP REST API: posts, pages, media, categories, tags, users, with _embed.
  • Store raw JSON first, transform second. You will re-run the transform.
  • Keep: slug, real date (not the bumped sitemap lastmod), modified, status, categories, tags, author, excerpt, featured image, SEO plugin meta (title, description, canonical, robots, focus keyword).
  • Parse block content for FAQ/HowTo. Rank Math stores FAQ and HowTo as block JSON, not rendered HTML. Parse the blocks, not content.rendered. This cost me days.
  • Convert HTML to Markdown/MDX. Keep headings, tables, code, and lists as close to the original as possible. Normalize the wp-block-* wrappers away.
  • Download original images, upload to your CDN, record width and height, rewrite URLs.
  • Verify: post count in the export = REST total minus decided exclusions. Randomly sample 20 posts and diff them visually against live.

1. Build with SEO parity

For every page type, reproduce from frontmatter:

  • <title>, meta description, canonical (self, absolute, include www), robots meta.
  • OpenGraph + Twitter card, og:image at the same aspect ratio.
  • JSON-LD schemas: Organization + WebSite + BreadcrumbList on every page, Article + FAQPage on content pages.
  • Sitemap with real lastmod dates (not the bumped WordPress ones).
  • robots.txt identical plus the new sitemap URL.
  • RSS at the same path (/feed/).
  • Redirect file from url-map.csv. 301 for moves, 410 for gone.
  • Trailing-slash policy identical to WordPress.
  • www canonical host, apex redirects to www, HTTP to HTTPS.
  • Every <img> has width, height, and alt. Fonts self-hosted, preloaded, font-display: swap.
  • Ad slots reserved with a fixed min-height (prevents CLS).
  • Analytics: same GA4 property, same AdSense publisher ID, ads.txt byte-identical.

1. Verify on a preview URL before DNS

  • Redirect test: request every URL in url-map.csv on preview, assert status and final URL. Zero failures.
  • Parity diff: run the Step 0 snapshot script on preview. Diff per URL: title, description, canonical, robots, H1, schema types, word count (within 10%), image count. Every difference explained or fixed.
  • Crawl: zero 4xx internal links, zero redirect chains, zero pages without a canonical.
  • Schema: Rich Results Test on one URL per page type.
  • Lighthouse: same URLs as baseline, mobile + desktop.
  • Sitemaps: URL count = keep-count from url-map.csv.
  • Ads render on preview with test mode. GA4 debug view receives page_view.
  • Feed validates. 404 returns a real 404 status.

1. Cut over

  • Lower DNS TTL 24 hours before.
  • Deploy the final build. Switch DNS.
  • Keep WordPress alive on a different hostname, noindex, basic auth, 30 days.
  • Same day: submit the sitemap in GSC and Bing, request indexing on the top 20 pages.
  • Same day: create a search-console annotation marking the cutover, naming the commit that shipped it.
  • Turn off WP cron, mail, and scheduled publishing (prevents redirect loops).

1. Monitor for 90 days

Daily for 14 days, weekly after:

  • GSC Page Indexing: watch "Not found (404)", "Redirect error", "Duplicate, Google chose different canonical".
  • GSC Performance: clicks and impressions vs the same weekday in the previous 4 weeks. A dip under 10% in week 1 is normal. Over 20% on a top-50 page: investigate that page's redirect and canonical the same day.
  • Bing: crawl errors, pages in index.
  • Server logs/analytics: top 404 paths. Add redirects for any path with real hits.
  • CWV field data: the GSC CWV report should flip from poor to good over 28 days.
  • AdSense: impressions per pageview and RPM. Watch for policy flags on new page layouts.
  • GA4: engagement rate and pages per session. A drop means broken internal links or slow ads.

1. Decommission

  • After 30 clean days: snapshot the WP database + uploads to cold storage.
  • Destroy the VPS (or repurpose it).
  • Remove old DNS records and old Cloudflare rules.
  • Keep the old GSC property for historical data.

Mistakes I made (so you do not)

1. Sitemap lastmod was meaningless

2,999 of the 3,000 WordPress posts carried a 2026 lastmod. Some plugin was bumping the modified date on every post. Search engines learn to ignore that. The new build emits real dates, but I did not catch this until after I had trusted lastmod as a "recently updated" signal. Verify your dates against the actual post edit history before migrating.

2. The FAQ/HowTo block trap

Rank Math stores FAQ and HowTo schema as WordPress block JSON embedded in post_content. It is not in content.rendered, and it is not in a separate meta field in the REST API. Parse the rendered HTML and you lose all structured data. I had to go back and re-parse the raw block content for every post that used those blocks.

3. Comments endpoint was locked

The WordPress REST API returned 403 on the comments endpoint. Not documented, not obvious. I exported the 55 comments through the database instead. Check the endpoint on day one, not the day you need it.

4. A quarter of the sitemap was already a redirect

44 of my top 150 GSC landing pages 301'd to a single how-to page (the old torrent-proxy posts). Google kept ranking the old URLs for months after the redirect was set. Every one of those redirects had to be carried into the new site exactly, because missing even one means a sudden 404 on a page Google is still sending traffic to.

5. Cloudflare firewall rules outlived the attack

"Under Attack" era firewall rules were still active long after the bot flood. Audit and drop them as part of the move. They add latency and break legitimate clients.

6. Content cannot be transformed at build time

Astro's render() reads from the filesystem. You cannot pass it a modified body. Any content transformation (stripping sections, rewriting markup) has to happen either in the CMS or after render. I tried to strip FAQ sections from article bodies at build time to render them as accordions instead. It silently produced empty output. The fix is to store the body in the shape you actually want.

What I am still fixing

This is not finished. Two things are still in flight:

  1. Mobile LCP regression. The critical-CSS pass shipped 2026-09-11; a re-check on 2026-09-18 (the trend chart above) shows the same bimodal pattern, including on a page that had briefly recovered. I am re-running the same five URLs on a schedule instead of trusting a single sample.
  2. +90 day search numbers for the real trend (around Dec 10).

+30 day search numbers (checked 2026-10-27)

Read these with one fact in mind: Google's August 2026 spam update hit the site on Aug 18, three weeks before the cutover. Daily impressions fell from about 25,000 to under 2,000 overnight. Then I removed low-value pages on purpose: 43 proxy and torrent pages redirected on Aug 28, 1,378 thin Roblox codes pages set to noindex on Aug 29, and 3,172 more codes pages cut on Oct 3. So the cutover landed on a site that was already shrinking, and these numbers cannot isolate the stack change.

Week (7 days)Google clicksGoogle impressionsAll sessions (GA4)
Sep 3 to 9 (last week on WordPress)2494,0051,414
Oct 4 to 10 (+4 weeks on Astro)901,5523,190
Oct 19 to 25 (+6 weeks on Astro)821,3982,997

Source: Google Search Console and GA4 for thetechbasket.com, pulled 2026-10-27. The clicks drop started before the cutover: clicks were already down from about 80 a day on Sep 3 to 4 to about 15 a day on Sep 7 to 9, as the redirected proxy pages left the index. Since cutover, clicks sit at about 12 a day and have not fallen further. Impressions fell more, which matches the Oct 3 page cut. Sessions from all channels roughly doubled. My read: no sign the move itself lost rankings, but no recovery from the spam update yet either.

The honest expectation: cost and most Core Web Vitals improve on day one, search takes 4 to 12 weeks to settle, and any eventual change in ad performance owes as much to the content pivot (dropping torrent bulk, growing quality pages like the gaming hub) as to the stack change. That part gets told straight when the numbers land.

FAQs

How long did the WordPress to Astro migration take?
The baseline was frozen on 2026-09-04 and the Astro build went live on 2026-09-10. The long part was not the code, it was the audit: mapping every old URL, reconciling the post count (the REST API said 3,994, the sitemap said 3,000), and rebuilding SEO parity page type by page type.
Did search traffic drop after moving to Astro?
Not that the data can show. Google search impressions had already dropped over 90% after the August 2026 spam update, three weeks before the cutover on 2026-09-10. Six weeks after the move, Google clicks hold at about 12 a day and total sessions doubled, while impressions fell after a planned cut of thin pages. The +90 day check is around Dec 10. Every old URL kept its path, every redirect was ported, and the sitemap, canonical and robots rules match the old site, so the move was built to protect rankings rather than gamble on them.
Is a static site actually faster than WordPress?
Not uniformly. Desktop and CLS got clearly better. Mobile LCP got worse on pages where WordPress was inlining critical CSS well, because the Astro build ships a bigger CSS payload. Static removes the origin, the database and the plugin stack, but it does not automatically win every metric on every device.
Why build a CMS instead of running WordPress headless?
WordPress admin was the part that worked, but it was also the part carrying 32 plugins and a database on the serving path. Keeping it headless would have kept the maintenance and security surface. A small purpose-built CMS, Boring CMS, holds the content and the Astro repo pulls it at build time, so the public serving path stays static. It is optional: plain Markdown in git works too. Boring CMS is published open source as boring-cms on npm if you want the same setup.
What happened to the 32 WordPress plugins?
Zero runtime plugins. SEO moved into frontmatter and build-time JSON-LD, the table of contents is generated from headings, ads are static slots, caching is the edge, and images are pre-sized on a CDN. The only third-party scripts left are analytics and an ad tag.
AYAmit Yadav

Amit Yadav

Contributor

Amit loves new tech and apps that make life easier, he writes about apps, games and useful websites on TheTechBasket.

843 articles writtenView all posts by Amit →

Related articles

See all