fix: prerender per-route <head> — interim SEO-collapse fix (unique title + self-canonical)#2
Closed
ProfessorManhattan wants to merge 2 commits into
Closed
fix: prerender per-route <head> — interim SEO-collapse fix (unique title + self-canonical)#2ProfessorManhattan wants to merge 2 commits into
ProfessorManhattan wants to merge 2 commits into
Conversation
…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>
Contributor
Author
|
Extended to the full per-route head — now also writes a unique per-route |
Contributor
Author
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem (confirmed live)
Client SPA → crawlers read the static
index.htmlshell → every route serves the homepage<title>+canonical=/→ Google/ChatGPT/Perplexity index one URL for the whole site (thealways.mdbuild-fail; live on projectsites.dev itself — see #1).This fix (interim, fully verified)
scripts/prerender-route-heads.mjs(postbuild) writesdist/<route>/index.htmlper static route with a unique<title>+ self-referencingcanonical(+ og:title/url). CF Pages serves these file-based, before the/* → /index.htmlfallback.Verified on a clean build — 13 per-route files:
/about/about/services/services/contact/contact(
{name}resolves per-site viaswap-brand.) Each route now self-canonicals → de-collapsed.validate-ssr-head(#1) now passes.Scope
vite-react-ssg). One-line removal once SSG lands.🤖 Generated with Claude Code