Skip to content
Advertisement

WordPress to Astro: 4,000 Posts, 0 Plugins

Amit YadavAmit Yadav··23 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.

Key takeaways
  • Nearly 4,000 WordPress posts rebuilt as fully static Astro. 32 active plugins to zero.
  • Mobile CLS fixed on the worst articles: 0.22-0.32 down to ~0.00. Two desktop pages still sit just over 0.1.
  • Article accessibility 83 to 95. Cache-hit ratio 4.2% to 100% at the edge.
  • Weakest pages gained most: mobile performance 25 to 56, 49 to 83, 51 to 90.
  • Mobile LCP regressed on some pages (render-blocking CSS, still fixing).
  • An 8-step checklist, from freezing your baseline to decommissioning the old stack.
  • Search numbers at +30 and +90 days: not available yet, will update.
Updated 2026-09-18

Added a five-day re-check of five key URLs after the critical-CSS pass, corrected the CLS and sitemap-lastmod numbers, and re-verified every figure here against the raw baseline capture. Search numbers at +30 and +90 days are still pending.

Why WordPress had to go

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

01

Every page hit the origin

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. A bot flood in August 2026 (19,000 threats, 6x normal request volume) landed straight on PHP with nothing in front of it.

02

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.

03

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).

04

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.

Pros3
  • Zero setup, working in an afternoon
  • Battle-tested editor and media library
  • Maintenance and security patched by someone else
Cons3
  • 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.

Design brief

Performance and "nothing extra." If a feature does not directly help someone write or publish a post, it does not go in Boring CMS.

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.
Honest finding, re-checked Sep 18
  • LCP is bimodal. Good runs land near 2-3s. Bad runs land near 8-9s, with FCP at 6-7s in the same run.
  • The HTML is not the bottleneck. It is served from cache in about 60ms.
  • CSS is the suspect. The build inlines roughly 130KB of critical CSS in the head (~17KB gzipped) and defers the full 185KB bundle. Images and the framework are not the cause.
  • Desktop is unaffected. LCP is about 1s across the board there.
  • The first fix did not hold. A critical-CSS pass shipped 2026-09-11, but a re-run five days later shows the same pattern, and one previously-improved page (Undertale codes) slipped back.
  • The plan: re-run the same URLs on a schedule and track it as a trend, not a single before/after. Small, verifiable passes, and no declaring it fixed once.

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.

Step 0

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.
Save it all in the repo

Put everything in a dated folder in your new repo. Date the folder. Run the exact same capture after launch: same URLs, same columns. That is how you get a real before/after, not a cherry-picked one.

Step 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.
Mistake I made

WordPress REST said 3,994 posts. The sitemap said 3,000. The gap was drafts, noindexed posts, and redirected posts. I should have reconciled these before exporting, not after. It cost hours of debugging missing pages.

Step 2

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.
Step 3

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.
Step 4

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.
Step 5

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).
Step 6

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.
Step 7

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)

01

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.

02

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.

03

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.

04

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.

05

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.

06

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. Three 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. +30 day search numbers (cutover was Sep 10, so ~Oct 10 is the earliest useful window).
  3. +90 day search numbers for the real trend.

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?
Too early to say. Cutover was 2026-09-10, and the +30 and +90 day windows, roughly Oct 10 and Dec 10, are when the signal becomes readable. 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.

4114 articles writtenView all posts by Amit →

Related articles

See all