Give every route its own title, description and link preview - #9
Merged
Merged
Conversation
The build is a single page, so every URL shipped one head. Google saw five pages wearing the same name, and a link shared on WhatsApp previewed as the homepage whichever page was sent. src/data/seo.js is now the one table, read by two consumers: - At build time vite.config.js emits a real HTML file per route, each with its own title, description, canonical and Open Graph tags. Nothing is rendered server-side; the body is the same empty root div and React boots exactly as before. Only the handful of tags a crawler or a link scraper reads before it stops are different, and those were the ones that were wrong. This is the only version WhatsApp, Facebook and Twitter ever see — none of them run JS. - At runtime src/lib/seo.js updates the live document on client-side navigation, by mutating the tags the build already shipped. React 19 can hoist <title> and <meta> itself, but it appends rather than replaces, which would leave two titles and two descriptions on every page. Two files per route — services.html and services/index.html — because which form GitHub Pages prefers is not worth guessing at when both cost 4kB. They share a canonical, so the duplicate collapses. /projects and /resources route and render but are still placeholders. They now carry noindex and are out of the sitemap; they were previously advertised to Google at priority 0.9 and 0.7. Unknown paths drop the canonical entirely rather than keep the previous page's — a stale canonical claims the 404 is a duplicate of a real page, which is a worse signal than making none. sitemap.xml and robots.txt are generated from the same table instead of being maintained by hand, so they cannot drift again. scripts/verify-seo.mjs guards all of it, and runs as its own CI step. This failure mode is silent: if the per-route emit stops running the site still builds, deploys and works — it just quietly goes back to one head. Checked the guard fails on a seeded regression, not only that it passes.
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 build is a single page, so every URL shipped one head. Google saw five
pages wearing the same name, and a link shared on WhatsApp previewed as the
homepage whichever page was actually sent.
src/data/seo.jsis now one table with two consumers.Build time —
vite.config.jsEmits a real HTML file per route, each with its own title, description,
canonical and Open Graph tags. Nothing is rendered server-side: the body is the
same empty root div and React boots exactly as before. Only the handful of tags
a crawler or link scraper reads before it stops are different — and those were
the ones that were wrong.
This matters because link scrapers never run JavaScript. WhatsApp, Facebook
and Twitter read the shipped HTML and stop, so a client-side-only fix would have
left previews broken while looking correct in the browser.
Two files per route (
services.htmlandservices/index.html) because whichform GitHub Pages prefers is not worth guessing at when both cost 4kB. They
share a canonical, so the duplicate collapses to one URL.
Runtime —
src/lib/seo.jsUpdates the live document on client-side navigation by mutating the tags the
build already shipped. React 19 can hoist
<title>and<meta>on its own, butit appends rather than replaces, so rendering them would leave two titles and
two descriptions on every page — and the browser keeps the first.
Also
/projectsand/resourcesroute and render but are still placeholders. Theynow carry
noindexand are out of the sitemap. They were previouslyadvertised to Google at priority 0.9 and 0.7.
stale canonical claims the 404 is a duplicate of a real page, which is a worse
signal than making none. Verified the tag is re-created on the next real route.
sitemap.xmlandrobots.txtare generated from the same table instead ofmaintained by hand, so they cannot drift again. The old one already had.
Verification
scripts/verify-seo.mjs, wired in as its own CI step vianpm run verify:seo.reverting one page's title to the shell title trips both the title check and
the shared-title check.
unknown → home, asserting tag counts at every step. Exactly one title, one
description and one og:description throughout; canonical present on real
routes, absent on unknown ones, re-created afterwards; robots added and
removed correctly.