Skip to content

feat(perf,seo): inject blog articles + beta status via window globals, optimize images#21

Merged
miquelmatoses merged 1 commit into
mainfrom
feat/blog-prerender-betabanner-images
May 16, 2026
Merged

feat(perf,seo): inject blog articles + beta status via window globals, optimize images#21
miquelmatoses merged 1 commit into
mainfrom
feat/blog-prerender-betabanner-images

Conversation

@miquelmatoses
Copy link
Copy Markdown
Collaborator

Summary

Consolidated fix for three production issues found in the audit:

  • Soft 404 in Google Search Console: /blog (all 6 languages) pre-rendered with the "Could not load articles" error fallback because the concurrent Puppeteer pool caused the api.cercol.team/blog API call to fail during the CI build. The 131c986 waitForFunction guard was also never deployed (scripts/ was missing from the deploy path filter). Fix: prerender.mjs now fetches the article list ONCE at build start (fetchBlogArticles() throws on failure to fail the build loudly), injects it as window.__BLOG_ARTICLES__ in every pre-rendered HTML. BlogIndexPage reads from window synchronously — no API call at render time.

  • Blog slug LCP 9.1s (722 KiB Unsplash image): 91 of 104 coverUrl values in the DB have malformed double-? query strings (...ixlib=rb-4.1.0?w=1200...), causing Unsplash CDN to ignore the size param and serve full-resolution images. Fix: new src/utils/unsplash.js normalizeUnsplashUrl() utility strips the broken suffix and applies correct ?w= params. BlogArticlePage uses w=760 + fetchpriority=high + loading=eager. BlogIndexPage thumbnails use w=400 + loading=lazy. 5 unit tests.

  • Landing LCP 4.4s (BetaBanner): useState(null) unmounts the pre-rendered banner during React hydration; the /beta API round-trip (~1300ms) then re-mounts it, shifting LCP. Fix: BetaBanner useState now initialises from window.__BETA__ injected by prerender, so React mounts with the same data as the pre-rendered HTML and never unmounts the banner.

  • Deploy path filter: scripts/** added so future prerender changes trigger automatic deploys.

Verification (local)

  • All 6 blog indexes: 104 article links, 0 "Could not load" occurrences ✓
  • window.__BETA__ and window.__BLOG_ARTICLES__ present in dist/index.html
  • window.__BLOG_ARTICLES__ (104 articles) present in dist/blog/index.html
  • Article cover <img>: single ?, w=760, fetchpriority=\"high\"
  • 199 tests pass (194 existing + 5 new unsplash tests) ✓

Test plan

  • CI green (build + bundle sanity + 199 tests + backend tests)
  • After deploy: /blog in all 6 languages returns 200 with >50 article links, 0 error fallback
  • window.__BLOG_ARTICLES__ visible in page source at cercol.team/blog
  • window.__BETA__ visible in page source at cercol.team/
  • Blog slug cover image src has single ? and w=760
  • In Google Search Console: request indexing for /blog — should now pass (no Soft 404)
  • Rerun PageSpeed on a blog slug — image LCP should drop significantly

🤖 Generated with Claude Code

…, optimize images

Production audit caught three remaining issues:

1. /blog pre-rendered HTML in production contains the 'Could not
   load articles' error fallback. Google Search Console rejects
   indexing with 'Soft 404'. Root cause: during the PR #20 CI build,
   BlogIndexPage's API call during prerender failed (likely due to
   the 4-page concurrent pool hammering api.cercol.team with too
   many simultaneous requests). Puppeteer captured the error state.
   Commit 131c986 (waitForFunction guard) was on main but never
   deployed (scripts/ missing from deploy path filter), AND even if
   deployed wouldn't help in this case — if the API stays failed
   during build, the guard times out and the error HTML is captured
   anyway.

2. 91 of 104 blog cover image URLs in the database have malformed
   double-? query strings, causing Unsplash to serve 3840px+ images
   at 380x214 display. The blog slug LCP is dominated by this:
   722 KiB cover image, LCP 9.1s.

3. BetaBanner causes 1300ms LCP delay on the landing page because
   useState(null) unmounts the pre-rendered banner during React
   hydration; the API roundtrip then re-mounts it 1300ms later.

The unified solution: stop depending on API calls at render time
for pre-rendered routes. The prerender script fetches the article
list and beta status ONCE and injects them as window globals into
every pre-rendered HTML. React reads from window synchronously,
eliminating the hydration flicker and the API failure mode.

Changes:

deploy-frontend.yml: add scripts/** to path filter so prerender
changes trigger fresh deploys.

src/utils/unsplash.js (new): normalizeUnsplashUrl() fixes the
double-? URLs and applies w/q/fit overrides. 5 unit tests.

BlogArticlePage.jsx: cover uses normalizeUnsplashUrl(coverUrl,
{w:760}) + fetchpriority='high' + loading='eager' + explicit
width/height.

BlogIndexPage.jsx:
- useState reads from window.__BLOG_ARTICLES__ on first render
- useEffect still refreshes from API for stale-data resilience
- Error state only shown when we have NO articles from ANY source
- Thumbnails use normalizeUnsplashUrl(coverUrl, {w:400}) + lazy

BetaBanner.jsx: useState reads from window.__BETA__ on first render.

scripts/prerender.mjs:
- fetchBlogArticles() called once at start (throws on failure to
  fail the build loudly rather than silently shipping broken HTML)
- fetchBetaStatus() called once at start (silent fallback to
  {remaining:500,total:500,active:true})
- Both values injected as <script>window.__BETA__=...;
  window.__BLOG_ARTICLES__=...;</script> in <head> of every HTML

Expected outcomes:
- /blog and 5 language variants: HTML contains 104 article cards,
  no 'Could not load' fallback. Google Search Console accepts
  indexing.
- Blog slug LCP: 9.1s -> ~2.5s
- Blog slug Performance: 66 -> ~85-90
- Landing LCP: 4.4s -> ~2.5-3.0s
- Landing Performance: 76 -> ~82-86

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@miquelmatoses miquelmatoses merged commit 1566372 into main May 16, 2026
3 checks passed
@miquelmatoses miquelmatoses deleted the feat/blog-prerender-betabanner-images branch May 16, 2026 22:25
miquelmatoses added a commit that referenced this pull request May 17, 2026
…r post-process (#22)

Closes the remaining performance gap from the PR #21 PageSpeed
follow-up: render-blocking CSS (~1100ms) and undiscovered critical
fonts (~400ms).

Approach: instead of running beasties as a Vite plugin (which sees
only the empty SPA shell), invoke beasties as a Node.js library
inside scripts/prerender.mjs, after each route's full DOM is captured
by puppeteer. Each of the 637 routes gets its own critical CSS
extraction matched to that page's above-the-fold content.

Font preloads are injected by the same prerender step. The 4 latin
woff2 files (Playfair Display 400, Roboto 400/500/700) are
content-hashed by Vite so the URLs change every build; the script
extracts them from dist/assets/index-*.css at runtime.

scripts/prerender.mjs changes:

- New helper extractCriticalFontUrls() parses the built CSS file
  to find the 4 critical font URLs with their content hashes
- New helper buildFontPreloadTags() generates the <link rel=preload>
  tags (with crossorigin, required by browser spec)
- New Beasties instance configured with pruneSource:false (the
  shared CSS file must remain complete — 637 HTMLs reference it),
  preload:'swap' (external CSS becomes non-blocking), and inlineThreshold:0
- startServer() now accepts and uses a frozen copy of the original
  Vite-built index.html as the SPA shell fallback; without this,
  routes processed after '/' would pick up the already-injected
  preload tags and produce 8 duplicate preloads instead of 4
- renderOneRoute now: injects font preloads + window globals into
  the <head>, then runs beasties.process() on the result, then
  writes the final HTML to disk
- If beasties fails on a specific page, log and fall back to the
  un-processed HTML rather than failing the build

package.json: beasties ^0.4.2 added as devDependency (no runtime
dependency).

Local verification:
- 4 font preloads on every route (/, /about, /blog, /ca/blog, slugs)
- 18-21 KiB critical CSS inlined per page
- CSS swap (preload + onload) pattern present
- Shared CSS file unchanged: 90,056 bytes (pruneSource:false)
- All 6 blog indexes: 0 errors, 104 links each
- 199/199 tests passing

Expected outcomes:
- Landing LCP: 4.5s → ~2.0-2.5s
- Landing Performance: 75 → ~85-90
- Blog slug LCP: 6.5s → ~2.5-3.5s
- Blog slug Performance: 66 → ~78-85
- Build time: +~14min on the prerender phase (acceptable for CI)

Co-authored-by: miquelmatoses <miquelmatoses@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant