Skip to content

Give every route its own title, description and link preview - #9

Merged
laveshparyani merged 1 commit into
mainfrom
feat/per-page-seo
Oct 1, 2026
Merged

laveshparyani merged 1 commit into
mainfrom
feat/per-page-seo

Conversation

@laveshparyani

Copy link
Copy Markdown
Collaborator

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.js is now one table with two consumers.

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 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.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 to one URL.

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> on its own, but
it appends rather than replaces, so rendering them would leave two titles and
two descriptions on every page — and the browser keeps the first.

Also

  • /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 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. Verified the tag is re-created on the next real route.
  • sitemap.xml and robots.txt are generated from the same table instead of
    maintained by hand, so they cannot drift again. The old one already had.

Verification

  • scripts/verify-seo.mjs, wired in as its own CI step via npm run verify:seo.
  • Confirmed the guard fails on a seeded regression, not just that it passes:
    reverting one page's title to the shell title trips both the title check and
    the shared-title check.
  • Walked the router through home → services → projects → unknown → about →
    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.
  • Existing CI guards still pass.

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.
@laveshparyani
laveshparyani merged commit 69c699c into main Oct 1, 2026
3 checks passed
@laveshparyani
laveshparyani deleted the feat/per-page-seo branch October 1, 2026 07:57
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