Most "SSG vs SSR vs ISR" content out there is written from documentation. Someone reads the framework, restates it, and you're left inferring the actual difference in performance and behavior. So I built a lab where you can just run the commands and see it yourself, no table to trust blindly.
astro-wp-seo-lab builds the same WordPress content four different ways with Astro 7.1.1, then serves all four side by side so you can compare them directly.
git clone https://github.com/nimajafari/astro-wp-seo-lab
npm install
npm run compare
That builds each arm into its own directory and serves them all at once.
| arm | url | what it is |
|---|---|---|
| ssg-full | http://localhost:4301 | everything prerendered at build time |
| ssr | http://localhost:4302 | rendered per request, no caching |
| ssr-cdn | http://localhost:4303 | per request plus CDN cache headers |
| route-cache | http://localhost:4304 | per request plus Astro 7 route caching |
| islands | http://localhost:4305 | static shell with deferred fragments |
Every page has a black bar at the top showing which arm rendered it and when. That timestamp is the instrument for most of what follows. First build takes a few minutes since each arm fetches from WordPress, later builds are faster because the Content Layer loader caches between them.
It ships pointed at a live WordPress install (oxyplug.com), but it works against any public WordPress site with the REST API exposed.
npm run probe -- https://your-site.com --save mysite
SOURCE=mysite npm run compare
probe checks what your own install actually exposes, REST API reachability, Yoast presence, permalink structure, then saves it under a name. Use the URLs npm run compare prints for your own site instead of the ones below, since those are generated from your own content.
Build time vs request time
This is the distinction most of the SSG vs SSR debate hinges on, and it takes about 30 seconds to see for yourself.
Open these two side by side and reload each a few times.
- http://localhost:4301/optimization/crl-ocsp-certificate-revocation-methods/ (ssg-full)
- http://localhost:4302/optimization/crl-ocsp-certificate-revocation-methods/ (ssr)
The ssg-full timestamp never changes. It was baked in at build time, and every visitor gets that same file.
The ssr timestamp changes on every single reload, milliseconds apart. That page is assembled from scratch for every request.
for i in 1 2 3; do curl -s http://localhost:4301/optimization/crl-ocsp-certificate-revocation-methods/ \
| grep -o 'RENDERED: [^<]*'; done
# identical three times
for i in 1 2 3; do curl -s http://localhost:4302/optimization/crl-ocsp-certificate-revocation-methods/ \
| grep -o 'RENDERED: [^<]*'; done
# three different values
Why this matters for SEO specifically, freshness and cost trade against each other directly. The SSR page reflects a WordPress edit instantly, the SSG page doesn't until you rebuild. But SSR does that work again for every visitor, and every crawler hit too. Googlebot crawling 10,000 URLs is 10,000 renders, and each one is also a live WordPress API call.
What caching actually is
Open route-cache next.
http://localhost:4304/optimization/crl-ocsp-certificate-revocation-methods/
Reload it repeatedly. The timestamp freezes, even though this arm renders on demand exactly like ssr does. Wait past its 300 second TTL and reload again, it updates once, then freezes again.
That's ISR. That's the whole mechanism. Render on demand, keep the result, serve the kept copy until something invalidates it.
curl -sD- -o /dev/null http://localhost:4304/category/optimization/ | grep -i x-astro-cache
# MISS on the first request, HIT after
Compare the actual cache instructions each arm sends.
curl -sD- -o /dev/null http://localhost:4302/category/optimization/ | grep -i cache # ssr, nothing
curl -sD- -o /dev/null http://localhost:4303/category/optimization/ | grep -i cache # ssr-cdn
ssr-cdn sends two different policies on purpose.
Cache-Control public, max-age=0, must-revalidate
CDN-Cache-Control public, s-maxage=120, stale-while-revalidate=600
Browsers get told not to cache, the CDN gets told to cache hard. You can purge a CDN, you can't purge a browser. Anything a browser caches stays stale until it expires and there's nothing you can do about it once it's out there, so browsers get the conservative policy and shared caches get the aggressive one.
To see a real CDN in front of this, spin up Varnish.
docker compose -f docker/docker-compose.yml up -d # Varnish on :8081
npm run build:ssr-cdn && npm run serve
curl -sD- -o /dev/null http://localhost:8081/category/optimization/ | grep -iE 'x-cache|age'
Request it repeatedly over half a minute and watch Age climb in step with elapsed time. A monotonically increasing Age header is the provider neutral proof that a response came from cache, it works the same way on Varnish, Vercel, Netlify, and Cloudflare.
What a crawler actually sees
This is the exercise most worth doing if you only try one thing from this post.
Open the islands arm in a browser.
http://localhost:4305/optimization/crl-ocsp-certificate-revocation-methods/
Scroll to the bottom. There's a "Related posts" list with five links, looks completely normal.
Now View Source, the actual source, Ctrl-U or Cmd-Opt-U, not devtools. Devtools shows the live DOM after JavaScript has run, View Source shows what the server actually sent.
Search for "Related posts". It's not there. What's there instead is this.
<p data-island-fallback>Loading related posts...</p>
curl -s http://localhost:4305/optimization/crl-ocsp-certificate-revocation-methods/ | grep -c 'Related posts'
# 0
curl -s http://localhost:4301/optimization/crl-ocsp-certificate-revocation-methods/ | grep -c 'Related posts'
# 1, same component, rendered inline
Same component, same data, the only difference is server:defer.
Measure the gap properly.
node harness/parity-render.mjs --arm islands --base http://localhost:4305
node harness/parity-render.mjs --arm ssg-full --base http://localhost:4301
| arm | parity | internal links hidden |
|---|---|---|
| ssg-full | 100% | 0 |
| islands | 87.5% | 5 |
Google does run JavaScript, but rendering is queued separately from crawling, so JS only content can get discovered days later, or not at all. Links found only after JS run late, and that delay compounds through everything they point to. Most other crawlers don't execute JS at all either, Bing is inconsistent about it, and the AI crawlers driving more and more referral traffic generally just fetch raw HTML.
The rule that falls out of this pretty cleanly, anything that has to be indexed belongs in the static shell, never in a deferred island.
Measuring instead of guessing
npm run measure
Builds each arm, hits four representative routes 25 times each, and reports percentiles, separating cold cache from warm cache requests, since averaging them together would make ISR look identical to SSR.
Two results worth understanding rather than just reading.
route-cache beats static file serving outright on most routes. Counterintuitive at first, most people assume prerendered HTML is the floor. But the in memory cache answers from RAM while static output still goes through the filesystem, so a warm lookup skips a step static serving can't.
ssr-cdn measures the same as plain ssr at origin. That's the control working correctly, its entire advantage comes from the CDN sitting in front of it, not from anything different in its own rendering code. If those two numbers ever diverged, that would mean the comparison had broken somewhere.
The full numbers from a live run.
Warm p75 TTFB by arm (ms)
route ssg-full ssr ssr-cdn route-cache
/optimization/crl-ocsp-certif 0.43 4.41 4.32 0.38
/category/optimization/ 0.3 2.58 2.55 0.26
/docs/oxy-image-audit/privacy 0.28 4.72 4.49 0.26
/sitemap-0.xml 0.23 2.27 2.22 0.28
ssr-cdn through Varnish, cold vs warm (the ISR claim)
route cold p75 warm p75 speedup
/optimization/crl-ocsp-certif 7.14 0.93 7.7x
/category/optimization/ 3.04 0.34 8.9x
/docs/oxy-image-audit/privacy 6.04 0.45 13.4x
/sitemap-0.xml 3.2 0.39 8.2x
These run on localhost with no network latency, which is why everything's under 10ms. The relative differences are the signal, the absolute numbers won't survive contact with a real network.
Crawling the site
npm run build:ssr && npm run serve
npm run crawl # in another terminal
Walks the site breadth first from the homepage, following only links in the served HTML, the way a crawler actually does it. It reports things a page by page check just can't.
Orphans, URLs in the sitemap that no internal link actually reaches. A sitemap entry is a suggestion, an internal link is the crawl path.
Click depth, hops from the homepage, which correlates with how often something gets crawled.
Redirect chains, status codes, canonical consistency, and JSON-LD validity per page.
Try breaking the navigation on purpose
Comment out <SiteNav /> in src/layouts/Base.astro, rebuild, and crawl again. Orphans jump from 0 to 73, out of 74 sitemap URLs only the homepage stays reachable.
That's not a contrived example, it's what happens by default. A headless frontend pulls post and page content, and a WordPress theme's navigation menu is not content. It lives in the theme and silently doesn't come across. Every page reachable only through the menu becomes an orphan, and nothing about those pages looks wrong, they render, they have canonicals, they're in the sitemap. They're just never linked to.
Would this migration actually be safe
npm run build:ssg-full
npm run parity
Fetches the live WordPress sitemap and checks that every URL it lists still resolves in the build, plus trailing slash consistency and that no canonical still points at the WordPress origin.
A green build proves nothing about this on its own. While building this lab, three separate failures exited 0 while being badly wrong, a build that emitted zero pages, an arm serving no cache headers at all, and a sitemap missing 40% of the site.
Break it on purpose to see what that looks like. Delete src/pages/category/[...page].astro, rebuild, and run npm run parity again. The category archive and all its paginated pages now 404. That's exactly what shipping without this check looks like, except in production nobody tells you.
What I'd actually change about how I recommend rendering strategies
Before running any of this, my working assumption was the usual one, static for content heavy sites, SSR with caching for anything needing freshness, full SSR only for logged in or genuinely uncacheable routes. The data mostly holds that up, but it moved where I'd draw the line.
The meaningful split isn't SSG vs SSR. It's cached vs uncached. Once a route is cached, whether that cache was written at build time or on first request, TTFB lands in the same tier either way. Astro 7's Route Caching API gets you SSG level warm performance without committing to a rebuild on every change.
Which means the real question for a migration isn't which rendering mode to pick. It's how a cache entry gets invalidated when someone hits publish, and what's stale in the window before that purge fires. harness/purge.mjs in the repo does dependency aware purging manually and resolves that fan out, but in production that trigger has to come from somewhere, and that's a real implementation decision, not a footnote.
Try it yourself
Everything above runs against any public WordPress site, not just the one this repo ships pointed at.
git clone https://github.com/nimajafari/astro-wp-seo-lab
npm install
npm run probe -- https://your-site.com --save mysite
SOURCE=mysite npm run compare
SOURCE=mysite npm run measure
The other failure modes this lab turned up (a sitemap plugin that silently dropped 40% of URLs with a green build, WordPress returning two different encodings for the same slug depending on which field you read, non Latin slugs failing the build entirely) are documented in the repo along with the harnesses themselves, which are commented as teaching material including the false positives that had to get fixed before their output could be trusted.
Repo is here if you want to poke at it, happy to answer questions in the comments.
Top comments (0)