Skip to content

fix: prerender per-route <head> — interim SEO-collapse fix (unique title + self-canonical)#2

Closed
ProfessorManhattan wants to merge 2 commits into
mainfrom
feat/prerender-route-heads
Closed

fix: prerender per-route <head> — interim SEO-collapse fix (unique title + self-canonical)#2
ProfessorManhattan wants to merge 2 commits into
mainfrom
feat/prerender-route-heads

Conversation

@ProfessorManhattan

Copy link
Copy Markdown
Contributor

The problem (confirmed live)

Client SPA → crawlers read the static index.html shell → every route serves the homepage <title> + canonical=/ → Google/ChatGPT/Perplexity index one URL for the whole site (the always.md build-fail; live on projectsites.dev itself — see #1).

This fix (interim, fully verified)

scripts/prerender-route-heads.mjs (postbuild) writes dist/<route>/index.html per static route with a unique <title> + self-referencing canonical (+ og:title/url). CF Pages serves these file-based, before the /* → /index.html fallback.

Verified on a clean build — 13 per-route files:

route title canonical
/about About — {name} /about
/services Services — {name} /services
/contact Contact — {name} /contact

({name} resolves per-site via swap-brand.) Each route now self-canonicals → de-collapsed. validate-ssr-head (#1) now passes.

Scope

  • Interim — fixes the critical de-collapse (unique title + self-canonical, which Google dedupes by). Per-route descriptions still need the proper fix (runtime-only React literals → resolve via vite-react-ssg). One-line removal once SSG lands.
  • Additive: one postbuild script. No app/runtime changes, no SSR-compat needed.

🤖 Generated with Claude Code

Brian Zalewski and others added 2 commits June 20, 2026 23:11
…se via unique title+canonical)

The app is a client SPA: crawlers read the static index.html shell, so every route shipped the
homepage <title>+canonical=/ → site-wide SEO collapse (confirmed live on projectsites.dev).
The client-side useSEO update is invisible to Googlebot/ChatGPT/Perplexity.

scripts/prerender-route-heads.mjs (postbuild) writes dist/<route>/index.html for each static
route with a UNIQUE per-route <title> + self-referencing <link canonical> (+ og:title/og:url).
CF Pages serves these file-based, BEFORE the /* → /index.html SPA fallback. Verified: 13
per-route HTML files, each /about→'About — {name}' canonical=/about (was homepage title +
canonical=/). The {name} placeholder resolves per-site via swap-brand.

INTERIM by design — fixes the critical de-collapse (unique title + self-canonical, which Google
dedupes by). Per-route DESCRIPTIONS still need the full fix (they're runtime-only React literals;
resolve via vite-react-ssg). Removing this script once SSG lands is a one-liner. validate-ssr-head
(PR #1) now passes (prerendered route HTML present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SEO — single source)

Extends the interim de-collapse (this PR) from title+canonical to the FULL per-route head.
The prerender now reads each route's description straight from its page useSEO() call
(src/pages/<Comp>.tsx), resolving the simple ${brand.business.name}/{BUSINESS_NAME}
substitutions — so the server <meta description> MATCHES the client one exactly (no divergence,
single source of truth). Also rewrites og:title/og:url/og:description per route.

Verified on a clean build: /team→'Meet the people behind {name}', /faq→'Answers about {name}',
/about→'{ABOUT_META_DESCRIPTION}' — each route now has a UNIQUE title + canonical + description
(was the homepage's on every route). Placeholders stay for the raw template (generator fills);
for a generated site swap-brand resolves src pre-build, so the prerender reads resolved copy.

Closes the per-route-head gap the title-only version left (checklist #50 unique meta desc).
Full proper fix is still vite-react-ssg; this interim now covers the complete crawler-visible head.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ProfessorManhattan

Copy link
Copy Markdown
Contributor Author

Extended to the full per-route head — now also writes a unique per-route <meta description> (+ og:description), read straight from each page's own useSEO() call so server and client match exactly (single source, no divergence). Verified clean build: /team→'Meet the people behind {name}', /faq→'Answers about {name}', /about→its own {ABOUT_META_DESCRIPTION}. Each route now ships a unique title + canonical + description (was the homepage's on every route — checklist #50). Full proper fix remains vite-react-ssg; this interim now covers the complete crawler-visible head.

@ProfessorManhattan

Copy link
Copy Markdown
Contributor Author

Superseded by #4 (browser-prerender renders full per-route body + head + JSON-LD via Playwright, vs this interim head-only fix). Closing in favor of #4.

@ProfessorManhattan ProfessorManhattan deleted the feat/prerender-route-heads branch June 24, 2026 19:11
ProfessorManhattan added a commit that referenced this pull request Jun 25, 2026
…ON-LD), zero refactor (#4)

* feat: browser-prerender (react-snap pattern) — full per-route SSR output, zero refactor

The COMPLETE fix for the crawler-invisibility class. The app is a client SPA: crawlers read
the static index.html shell, which has an EMPTY <div id=root> (0 chars) → they see no body
content, the homepage head on every route, and no JSON-LD (it's client-rendered). PR #2 fixed
the head; this fixes EVERYTHING at once with no component/SSR-compat changes.

scripts/prerender-spa.mjs (npm run prerender) serves dist/ via a tiny node http server (SPA
fallback) and runs the BUILT app in headless Chromium per route (Playwright — already a dep),
capturing the fully-rendered HTML → dist/<route>/index.html. Crawlers (no JS) now get real
content; users still hydrate the app.

Verified on a clean build: /about went from 0 chars in #root → 138 words of body content +
per-route <title> + JSON-LD present. Run in CI/GHA where Chromium is available (the template's
e2e CI already has Playwright); supersedes the no-deps head-only prerender (#2, which stays as
a Chromium-free fallback). Satisfies the new validate-body-content + validate-ssr-head gates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: prerender ALL indexable routes from sitemap (incl dynamic blog/case-study posts)

The first cut hardcoded 12 static routes — leaving dynamic /blog/:slug + /case-studies/:slug
posts client-only (still SEO-collapsed). Now discoverRoutes() reads dist/sitemap.xml and
prerenders EVERY indexable URL. Path extraction strips scheme+host OR the {BUSINESS_URL}
placeholder, so it works for both the raw template and generated sites (real domains).

Verified: 12 → 19 routes prerendered, including all 6 blog posts. Sample
dist/blog/doe-law-wcag/index.html → 150 words of body content + per-post <title>. No
indexable route left client-only — the SEO-collapse fix is now COMPLETE across the whole site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run the prerender before deploy (so the SSR fix actually applies to live sites)

PR #4 added scripts/prerender-spa.mjs + 'npm run prerender' but nothing INVOKED it on deploy —
the script would have existed but never run, so deployed demo sites would still ship the
client-only SPA (SEO-collapsed). Wired it into demo-deploys.yml between Build and Deploy:
install Chromium (GHA has it) → npm run prerender → wrangler pages deploy dist. Now the
deployed dist contains the per-route prerendered HTML (body + head + JSON-LD).

Deploy is GHA wrangler-action (not CF-Pages-git-integration), so Chromium is available — the
browser-prerender approach works in this pipeline. Generated-customer-site deploy pipelines
should mirror this build→prerender→deploy order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run prerender before deploy in deploy-template.mjs + deploy-applied.mjs too

Completes the deploy-path coverage. Last commit wired the prerender into the GHA demo-deploys
workflow, but the two SCRIPT-based deploy paths (deploy-template.mjs, deploy-applied.mjs) still
ran vite build → wrangler pages deploy with NO prerender — so a script-driven deploy would still
ship the client-only SPA (SEO-collapsed). Inserted 'node scripts/prerender-spa.mjs' right before
each 'wrangler pages deploy dist' (dist/sitemap.xml is present by then, so all routes prerender).

Now EVERY deploy path in the repo (GHA workflow + both scripts) server-renders body+head+JSON-LD
per route before shipping. Both scripts node --check clean. (Operator needs Chromium locally:
npx playwright install chromium — same as the e2e job.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Brian Zalewski <hey@megabyte.space>
Co-authored-by: Claude Opus 4.8 (1M context) <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