The GSMX blog moved from WordPress to static Astro and, for readers, only the speed changed. The addresses, posts and ads are the same, and the forum next door works as before. I checked 2,299 old addresses and none of them leads to the wrong place. On mobile, PageSpeed Insights went from 33–85 to 99–100 (median of 3 runs), and the server answers in under 60 ms instead of over a second.
In short:
- Same address. The blog stayed at
gsmx.pl/blog/. A Cloudflare Worker bound to that path alone serves it, so the forum never noticed. - No address was lost. Posts kept their slugs exactly. Only what was broken changed, and every such change has a 301 redirect.
- Before and after, measured the same way. The same 5 addresses, the same Lighthouse and the median of 3 runs. Without that the comparison means nothing.
- The ads stayed. They are now the heaviest part of the page, not the blog.
- Going back was always one command away. WordPress stayed on the hosting for a 4-week observation period.
The starting point: WordPress next to a forum
gsmx.pl is first of all a forum, which runs at the root of the domain. The GSM news blog lived in the /blog/ directory, on a separate WordPress install, with posts going back to 2006.
The state on 25 October 2026, from production and from a copy of the database:
- 26 installed plugins, 20 of them active: Jetpack, Rank Math, Quick AdSense Reloaded, WP Hide, Fast Velocity Minify, Sucuri, Disqus Conditional Load, an SEO auto-linker and more. On top of that the Presso theme with a child theme, and three sets of tables in the database left over from old installs.
- 33–42 scripts and 8–10 stylesheets per page. The HTML went through Cloudflare uncached, and the server answered in 0.8–1.6 s.
- Posts weighed 14–17 MiB according to PageSpeed Insights. Most of that weight was video ads loaded on every post.
- 20 years of content: several hundred posts, 705 tags, Flash embeds, images on tinypic and fotosik, which no longer exist, galleries, tables and styles written into the HTML.
Search traffic was negligible: 16 clicks in 16 months. That matters for the risk. The migration could not hurt SEO that was barely there, and I am not promising more traffic here. The reasons were different. I wanted a site with no PHP and no database, so nothing to patch. I wanted to be able to move it to any static host. And I wanted to publish new posts from the repository, without logging into a panel.
Measure “before” first
Before changing anything, I measured production. An “after” measurement only means something if you repeat exactly the same thing, so I fixed the method at the start and kept it to the end:
- 5 addresses: the blog home page, a category and three posts from 2026, 2010 and 2006. The old posts carry the most old HTML.
- Lighthouse 12 locally, 3 runs per address and mode, median. A single run lies. In PageSpeed Insights two posts with almost identical HTML scored 85 and 33.
- PageSpeed Insights and GTmetrix as public, verifiable snapshots.
- The full list of addresses from the WordPress REST API: posts, pages, categories, tags, authors, media. That list later became the redirect test.

One thing could not be measured. Google had no real-user data for the blog (CrUX: “No data” for all 5 addresses), because traffic was too low. So the comparison is a lab comparison, and I describe it as one.
What I considered and dropped
How it works: a Worker on /blog only
The whole gsmx.pl domain goes through Cloudflare. The blog is handled by a Worker with Static Assets, bound to two routes: gsmx.pl/blog and gsmx.pl/blog/*. Everything else reaches the forum server, as before the migration.
gsmx.pl/blogandgsmx.pl/blog/*the blog Worker on Cloudflare
the rest of the domain
the PHP forum, unchanged
The _redirects file
1,466 301 rules from old addresses, generated by the converter.
A finished file from dist/
The address
/blog/slug/is the filedist/blog/slug/index.html, with no code run.Worker code, only where a file is not enough
- old
?p=addresses - 410 for
wp-admin,xmlrpc.phpand removed tags - date archives, RSS, old thumbnails
- old
URL contract test
2,299 old addresses, 0 failures
200 for a post, 301 to the right place, 410 for a removed one.
A gsmx.pl/blog* route looks simpler, but it would also catch forum addresses that start with “blog”, such as /blogi/. Hence two exact routes, and the post-switch test checked the forum as well.
Astro builds the blog with base: '/blog' into dist/blog/, and the Worker serves the whole of dist/. The address /blog/slug/ then maps to the file dist/blog/slug/index.html, and Cloudflare returns it without running any code. The Worker code only runs where a file is not enough:
- old addresses with parameters, such as
/blog/?p=1234, are redirected to the right post; - 410 Gone for
wp-admin,wp-login.php,xmlrpc.php,wp-jsonand removed tags, so Google knows it is deliberate and not an outage; - date archives, RSS and old thumbnail paths, which a static rule cannot describe.
The other redirects, 1,466 rules, live in the _redirects file. The converter generates it; nobody writes it by hand. That file has to sit at the root of dist/, not in dist/blog/, because that is the only place Cloudflare reads it. In dist/blog/ it would just be a downloadable file.
Cloudflare redirects addresses without a trailing slash (/blog/slug) with a 307, not a 301 as WordPress did. I accepted that on purpose: the canonical addresses have the slash, and the versions without it barely appeared in links or in Search Console.
The converter: content, media and 20 years of leftovers
The converter reads post content from the WordPress REST API (content.rendered) and the rest from a local copy of the database. Rendered content already has the shortcodes, galleries and paragraphs expanded, which is what the reader saw. Rebuilding that from the raw content in the database would be a large, fragile piece of code for several hundred posts. From the database I take what REST does not give: Rank Math meta titles and descriptions, relations and the media map.
The old posts stayed as cleaned-up HTML inside Markdown files, so they look the way they did. New posts are written in plain Markdown.
What the converter did along the way, according to the migration report:
- 13 YouTube embeds from the Flash era became a lightweight player that loads on click. 4 other Flash embeds became a note with a link.
- It checked every image on a third-party host. 21 still worked, so it downloaded them onto the blog. 152 images in 53 posts pointed at services that are gone (tinypic, fotosik, imageshack and old domains). It removed them from the content and listed them in the report.
- It prepared the media: 508 files, originals up to 1,600 px and WebP variants with
srcset,widthandheight, so images do not push the text around. - It fixed what was broken: 3 slugs with typographic dashes and quotes percent-encoded in the address, subcategories reachable at two addresses, a category called
bada-2, 7 pairs of duplicate tags (wi-fiandwifi,app-storeandappstore…). Every change got a 301, and the converter rewrote internal links in the content to the new addresses straight away.
Working, indexed addresses stayed, even when they were not pretty. Changing an address has a cost, and a prettier slug brings nothing.
The URL contract test: 2,299 addresses, 0 failures
The hard part of a migration is not the templates, it is the old addresses. So the full list of WordPress addresses became a script that checks each one against the contract: 200 for a post, 301 to the right place for a changed address, 410 for a removed one.
I ran it on staging before the switch-over and on production right after it:
| Address source | Addresses | Failed |
|---|---|---|
| Posts from the REST API | 336 | 0 |
| Tags from the REST API | 705 | 0 |
Old ?p= addresses | 707 | 0 |
Media (WP Hide and wp-content paths) | 300 | 0 |
| Thumbnails | 194 | 0 |
| Authors, categories, pages | 33 | 0 |
Other: RSS, date archives, sitemaps, ads.txt, GSC verification | 24 | 0 |
| Total | 2,299 | 0 |
That was the condition for the switch-over: 100% or no deploy.
A switch-over without risk
Before binding the Worker to production, I checked the Cloudflare zone. Redirect Rules and Page Rules run before a Worker, so one old rule on /blog would take the traffic. Rocket Loader or email obfuscation would change the HTML. Staging was in the same zone, so I compared its response with the file in dist/. The only difference was the Web Analytics script.
The switch-over itself was a wrangler deploy with two routes, on 2 November 2026 at 08:02 UTC. WordPress was not shut down. It stayed on the hosting; traffic to /blog simply stopped reaching it. Going back means removing those two routes and takes seconds.
- Step 1
25 Oct: plan and "before"
The migration spec, Lighthouse, PageSpeed Insights and GTmetrix on 5 addresses, the full address list.
- Step 2
25 Oct: converter and Astro site
Content from the REST API, media, the redirect map, the new blog layout.
- Step 3
25–27 Oct: Worker on staging
staging.gsmx.pl, the URL contract test, PageSpeed Insights on staging.
- Step 4
30 Oct: categories and descriptions
A new category structure, separate fields for the H1, the SEO title and the descriptions.
- Step 5
2 Nov: switch-over
Worker routes on production, the 2,299-address test, sitemaps in Search Console.
- Step 6
2 Nov: "after"
The same script, the same addresses, the same location as 8 days earlier.
- Step 7
7 Nov: PageSpeed Insights and GTmetrix
Public measurements on production, three ad fixes and 3 PageSpeed Insights runs.
Results: before and after
The same method as before the migration: Lighthouse 12 locally, 3 runs, median. The migrated site carries AdSense, just like WordPress did.
| Page | Mobile | Desktop | LCP (mobile) | CLS (mobile) | Server response | Requests | Scripts | Weight |
|---|---|---|---|---|---|---|---|---|
| Home page | 36 → 94 | 80 → 100 | 11.08 → 2.21 s | 0.335 → 0.039 | 1,518 → 59 ms | 83 → 28 | 36 → 5 | 2,433 → 709 KB |
| Category | 61 → 93 | 91 → 100 | 5.79 → 2.20 s | 0.063 → 0.039 | 1,407 → 48 ms | 87 → 28 | 38 → 5 | 1,315 → 717 KB |
| 2026 post | 68 → 86 | 91 → 100 | 4.93 → 1.89 s | 0.075 → 0.042 | 1,059 → 46 ms | 74 → 21 | 42 → 5 | 1,150 → 505 KB |
| 2010 post | 60 → 89 | 89 → 100 | 6.66 → 2.21 s | 0.136 → 0.051 | 1,222 → 46 ms | 78 → 20 | 42 → 5 | 1,730 → 474 KB |
| 2006 post | 49 → 87 | 89 → 100 | 6.77 → 1.74 s | 0.155 → 0.045 | 1,609 → 26 ms | 73 → 20 | 42 → 5 | 1,581 → 474 KB |
Server response changed the most. WordPress assembled every page in PHP on every request. Now Cloudflare returns a finished file from the nearest server. In this measurement all 5 pages score 100 on desktop.
PageSpeed Insights and GTmetrix
A Lighthouse run on my own machine is easy to doubt, so I repeated the public measurements too: PageSpeed Insights on the same 5 addresses, mobile and desktop, plus GTmetrix from Seattle. Before the migration I ran PageSpeed Insights once per address. After it I ran three independent runs and report the median, because a single run can be a dozen points off. Every result links to a report.
| Page | Mobile | Desktop | Weight (mobile) |
|---|---|---|---|
| Home page | 51 → 99 | 98 → 100 | 2,761 → 536 KiB |
| Category | 62 → 100 | 85 → 99 | 2,005 → 519 KiB |
| 2026 post | 52 → 99 | 61 → 96 | 15,759 → 620 KiB |
| 2010 post | 85 → 100 | 92 → 100 | 14,359 → 618 KiB |
| 2006 post | 33 → 99 | 62 → 99 | 16,945 → 515 KiB |
Before the migration: 25 October 2026, a single run, Lighthouse 13.5. After the migration: 7 November 2026, median of 3 runs, Lighthouse 13.6.
Across all 30 runs after the migration, mobile scores 97–100 and desktop 95–100. On WordPress it was 33–85 and 61–98. The 2006 post had a Total Blocking Time of 7,690 ms and now has 40–160 ms. Posts weigh about 0.5–0.6 MiB instead of 14–17 MiB. Accessibility and SEO score 100, against 79–96 and 91–92 on WordPress.
What the first measurement caught
The first PageSpeed Insights run after the migration was less even. The home page scored 90 on desktop, and two old posts had a CLS of 0.084 and 0.113. The local Lighthouse run had not shown it. Every time the culprit was the in-content ad slot that AdSense left unfilled. From Poland the ad usually shows; PageSpeed Insights measures from the US and often gets none.
There were three causes, and I found them one after another:
- The empty ad slot disappeared at once and the text under it jumped about 300 px. Now it disappears only while it is below the screen.
- AdSense first gives the ad 280 px and, when there is none, takes it out of the page layout. A slot reserved at 250 px fell back to that height and the paragraph under it moved up 30 px. The reservation is now 280 px.
- An empty ad gets a width of 0 from AdSense. Centred, it moved its contents 330 px to the right. It is now left-aligned.
After these fixes desktop CLS is at most 0.011, and the home page scores 100 on all three runs. The general lesson: a static page does not move on its own. What moves it is whatever you load on top of it from elsewhere, and that has to be measured from more than one place, not just from your own machine.
GTmetrix (Seattle, desktop, home page): grade C → A, performance 62% → 90%, structure 89% → 99%, LCP 3.3 s → 0.38 s. Reports: before and after. That GTmetrix run predates the ad fixes.

What is left: the ads
On mobile the score stops short of 100 because of the ads, not the blog. On staging, PageSpeed Insights broke down the CPU time on the 2026 post:
- Google ad scripts: 4.2 s;
- the ad consent dialog: 1.4 s;
- the blog’s own JavaScript: 41 ms.
When an ad manages to load during the test, the score drops (that post on staging: 69). When it does not, the score is 99–100. There is no fixing that without removing the ads, and the ads stay, because they pay for the blog.
Two ad-related issues only showed up on production:
- The first “after” run measured CLS of 0.155–0.215 on short old posts. The in-content ad slot only got its height once the ad loaded, and pushed the paragraph below it down. Now every slot reserves 250 px from the first render, and CLS dropped to 0.04–0.05. What remains comes from Google’s consent dialog, not from the blog’s code.
- AdSense Auto ads placed themselves wherever they liked. I turned them off for the whole of
/blog/, and the blog has 4 ad units of its own in fixed places. The forum keeps Auto ads as before. The video ads, which weighed a dozen-plus MiB per post, went away together with WordPress.
What I do not know
The comparison is a lab comparison. On WordPress, Cloudflare Web Analytics collected only 4 measurements from the blog before the switch-over, so I have no real-user “before”. Nor do I have proof that a faster blog will get more search traffic. At 16 clicks in 16 months any change would be noise. The observation period runs until the end of November; after that WordPress will be removed from the hosting.
After the migration: posts from the repository
A static blog does not mean a blog without new posts. A post is a Markdown file in the repository. On gsmx.pl new topics are collected from RSS feeds, and the same automation drafts the post with sources and a cover and opens a pull request. Before the merge every draft goes through a fact check against its sources, and then the blog is built and shipped to Cloudflare with wrangler deploy. 15 posts went out that way in the first week after the migration. A post dated in the future waits and appears with the first build after that date.
Topic
Automation collects topics from RSS feeds, or an author writes the post themselves.
Pull request
The post as a Markdown file, with sources and a cover.
Review
- facts checked against the sources
- copy and publish date
Merge and deploy
Astro build and
wrangler deployto Cloudflare.Published
The post on gsmx.pl/blog
No panel, no database, no PHP.
What comes next: the forum
The forum on gsmx.pl still runs on XenForo, which means PHP. I am testing a few approaches for it as proofs of concept and MVPs. Most recently I moved it to Flarum as a test. Flarum’s front end is built on jQuery and a fairly dated approach, though, so Flarum will most likely stay only as the back end (headless), with Astro and Vue taking over the forum’s pages. More on that soon.
When a migration like this makes sense
Static Astro fits when content changes at publishing time rather than on every visit. That is true of a blog, a news site and a company website. The number of authors does not matter. On WordPress the GSMX blog had 17 of them, and all of them were moved over. Now anyone with access to the repository can be an author: they write a post as a Markdown file in an editor such as VS Code or with an AI assistant, and open a pull request. On GSMX every author is technical, so nobody misses the panel.
When the authors are not technical and want to keep a panel, WordPress can stay as the CMS while Astro only renders the content. That is how polo.blue has run since 2023. I describe both options on the WordPress to Astro migration page.
If your WordPress is slow and the plugin list grows with every update, start with a measurement and a list of addresses, as here. Those two things decide whether a migration succeeds. The templates are the easy part.




