On this page
- Why WordPress had to go
- Every page hit the origin
- Core Web Vitals were bad exactly where it mattered
- Page weight was absurd for what the site does
- Security and maintenance were a permanent tax
- What one post actually cost
- What replaced it
- The CMS problem
- What changed: the numbers
- What improved
- What regressed: mobile LCP
- Full mobile results (2026-09-13)
- Desktop results (2026-09-13)
- The migration checklist (8 steps)
- Freeze everything before you touch anything
- Decide every URL's fate
- Export content
- Build with SEO parity
- Verify on a preview URL before DNS
- Cut over
- Monitor for 90 days
- Decommission
- Mistakes I made (so you do not)
- Sitemap lastmod was meaningless
- The FAQ/HowTo block trap
- Comments endpoint was locked
- A quarter of the sitemap was already a redirect
- Cloudflare firewall rules outlived the attack
- Content cannot be transformed at build time
- What I am still fixing
- FAQs
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.
- 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.
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.
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.
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.
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).
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 document | 220 KB | 208 KB |
| HTML over the wire (gzip) | 63 KB | 35 KB |
| External JavaScript files | 11 | 6 (3 first-party, under 7 KB) |
| External CSS files | 7 | 1 (deferred) |
| Inline JavaScript in the HTML | 110 KB | 12 KB |
| Inline CSS in the HTML | 37 KB | 130 KB (critical, first paint) |
| Runtime dependencies | 32 plugins, PHP + MySQL | 0 |
| Cache header | none (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.
- Zero setup, working in an afternoon
- Battle-tested editor and media library
- Maintenance and security patched by someone else
- 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.
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.
What improved
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
| Series | WordPress (Sep 4) | Astro launch (Sep 13) | Re-run (Sep 18) |
|---|---|---|---|
| Homepage | 90 | 59 | 58 |
| Deals | 90 | 59 | 58 |
| About | 89 | 57 | 59 |
| Contexto | 90 | 53 | 59 |
| Undertale codes | 51 | 90 | 58 |
- 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.
| Page | Type | Perf | LCP | CLS | TBT |
|---|---|---|---|---|---|
/todays-contexto-answer/ | Word game | 53 | 8.1s poor | 0.001 good | 300ms needs work |
/loomian-legacy-codes/ | Codes | 56 | 8.5s poor | 0.002 good | 230ms needs work |
/about/ | Page | 57 | 8.2s poor | 0.035 good | 220ms needs work |
/zachs-service-station-codes/ | Codes | 57 | 9.5s poor | 0 good | 120ms good |
/ | Home | 59 | 9.0s poor | 0 good | 100ms good |
/dank-murderer-2-codes/ | Codes | 59 | 7.5s poor | 0.003 good | 160ms good |
/deals/ | Page | 59 | 8.7s poor | 0 good | 80ms good |
/war-machines-codes/ | Codes | 59 | 9.1s poor | 0 good | 60ms good |
/gaming/ | Hub | 65 | 2.9s needs work | 0.003 good | 1090ms poor |
/breaking-point-2-codes/ | Codes | 83 | 2.6s needs work | 0 good | 430ms needs work |
/rng-battles-codes/ | Codes | 84 | 3.7s needs work | 0 good | 120ms good |
/jailbird-codes/ | Codes | 89 | 1.8s good | 0.002 good | 390ms needs work |
/undertale-timeline-reset-codes/ | Codes | 90 | 2.6s needs work | 0 good | 230ms needs work |
/how-to-access-blocked-websites/ | How-to | 94 | 1.8s good | 0.001 good | 230ms 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.
| Page | Type | Perf | LCP | CLS | TBT |
|---|---|---|---|---|---|
/todays-contexto-answer/ | Word game | 65 | 1.1s good | 0.004 good | 1080ms poor |
/loomian-legacy-codes/ | Codes | 88 | 1.0s good | 0.135 needs work | 170ms good |
/about/ | Page | 93 | 0.9s good | 0.003 good | 190ms good |
/zachs-service-station-codes/ | Codes | 97 | 0.7s good | 0.008 good | 120ms good |
/ | Home | 89 | 1.1s good | 0.001 good | 230ms needs work |
/dank-murderer-2-codes/ | Codes | 79 | 1.1s good | 0.009 good | 400ms needs work |
/deals/ | Page | 97 | 0.8s good | 0.001 good | 120ms good |
/war-machines-codes/ | Codes | 93 | 0.8s good | 0.008 good | 200ms needs work |
/gaming/ | Hub | 92 | 0.9s good | 0.012 good | 210ms needs work |
/breaking-point-2-codes/ | Codes | 72 | 1.1s good | 0.007 good | 510ms needs work |
/rng-battles-codes/ | Codes | 97 | 1.0s good | 0.007 good | 90ms good |
/jailbird-codes/ | Codes | 96 | 0.5s good | 0.008 good | 170ms good |
/undertale-timeline-reset-codes/ | Codes | 92 | 1.0s good | 0.136 needs work | 110ms good |
/how-to-access-blocked-websites/ | How-to | 94 | 1.0s good | 0.008 good | 160ms 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.
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 -shon uploads). - Hosting cost per month.
- Screenshots of each page type, mobile and desktop.
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.
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.csvwith 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.
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.
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.
Build with SEO parity
For every page type, reproduce from frontmatter:
<title>, meta description, canonical (self, absolute, includewww), robots meta.- OpenGraph + Twitter card,
og:imageat the same aspect ratio. - JSON-LD schemas: Organization + WebSite + BreadcrumbList on every page, Article + FAQPage on content pages.
- Sitemap with real
lastmoddates (not the bumped WordPress ones). robots.txtidentical 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.
wwwcanonical host, apex redirects towww, 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.txtbyte-identical.
Verify on a preview URL before DNS
- Redirect test: request every URL in
url-map.csvon 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.
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).
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.
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)
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.
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.
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.
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.
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.
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:
- 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.
- +30 day search numbers (cutover was Sep 10, so ~Oct 10 is the earliest useful window).
- +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?
Did search traffic drop after moving to Astro?
Is a static site actually faster than WordPress?
Why build a CMS instead of running WordPress headless?
What happened to the 32 WordPress plugins?

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




