Skip to content

ADR-0004: Make src/content/seo.js the single source of truth for site-default SEO meta

Context

Husky's website renders SEO / OG / Twitter meta tags in two places:

  1. Runtime (per-page)src/components/Shared/SEO.jsx is a react-helmet-async component that takes title, description, image, path as props and rewrites <head> after JS hydrates. Pages that need a custom title/description (Methodology, About, Insights posts) call <SEO …/> and override the defaults.
  2. Build-time (site default)index.html ships hardcoded <meta name="description">, <meta property="og:description">, and <meta name="twitter:description"> tags. These are what social previewers (LinkedIn, Facebook, Twitter, Slack, iMessage) and JS-disabled crawlers see, because those clients fetch the HTML once and do not run React.

Today (2026-05-06) a viewer reported the homepage hero showed 5B+ MAIDs · 295 branded segments · 13 APAC markets with global reach while the HTML head meta still said 350M MAIDs · 3.5B monthly signals · 13 APAC markets — the pre-USA-discovery framing from before the 2026-04-22 Snowflake footprint review. The fix was a manual three-tag edit in index.html (commit 07ccd41). This is the second time in three weeks the index.html fallback has drifted from the canonical messaging in src/content/messaging.js after a brand-narrative change.

The drift cost is asymmetric: - Runtime SEO is mostly fine — pages using <SEO …/> get fresh content from siteConfig.tagline / page props - HTML fallback is the only thing social platforms see — and they cache aggressively, so a stale tag harms preview-card credibility for weeks - There is currently no link between the two surfaces. messaging.js does not import into index.html, and index.html is hand-edited

The number changes every time the data footprint changes (350M → 5B+ already happened; expect more changes as agg-ip / agg-ctv ship and the segment count moves off 295). Hand-edit-three-files-each-time is not a sustainable process.

Decision

Introduce src/content/seo.js as the single source of truth for site-default SEO content (siteTitle, siteDescription, ogImage, siteUrl). Both surfaces consume it:

  • Runtime path: SEO.jsx imports defaults from seo.js (currently mixes siteConfig.tagline and hardcoded fallbacks)
  • Build path: a small Vite transformIndexHtml plugin reads seo.js at build time and substitutes <!--%META_DESCRIPTION%--> / <!--%OG_DESCRIPTION%--> / <!--%TWITTER_DESCRIPTION%--> placeholders in index.html with the values from seo.js

Net change to index.html: three string literals become three placeholder comments. Net change to vite.config.js: a ~25-line custom plugin. Net change to runtime: SEO.jsx imports from seo.js instead of mixing sources.

Explicitly not doing: - Server-side rendering / static pre-rendering of every route (overkill — the homepage index.html is the only entry social clients fetch) - Switching to Astro / Next.js (architectural cost dwarfs the benefit at current scope) - Per-page meta injection at build time (only the homepage default needs it; per-page is already covered by SEO.jsx Helmet)

Alternatives considered

  • Status quo + manual checklist — every brand-narrative change requires editing both messaging.js and index.html. Cheap to set up, expensive every time, prone to silent drift. Rejected: today is the second drift incident in three weeks, the checklist would have to live in someone's head, and "next time" never works.
  • react-helmet-async only, drop the index.html fallback — let Helmet rewrite <head> post-hydration and accept that social previewers see whatever's in the bare HTML. Rejected: social previewers do not run JS. LinkedIn/Facebook/Twitter would lose preview cards entirely. The fallback IS the social SEO.
  • Static pre-render via vite-plugin-prerender / react-snap — render every route to static HTML at build time so the full Helmet-managed head ships pre-hydrated. Rejected for now: solves a bigger problem than we have (per-page social previews matter less than homepage), adds CI minutes, requires testing on a SPA that uses client-side routing. Worth revisiting if Insights post pages start needing per-post OG cards.
  • vite-plugin-html with EJS templating — community plugin, more powerful, more dependencies. Rejected: a 25-line homemade plugin reading one ES module is simpler and has no supply-chain surface.
  • Move SEO defaults to siteConfig.js — already-existing module, would reduce file count. Rejected: siteConfig.js mixes brand identity (name, URL) with operational config (analytics IDs); SEO description / OG image / Twitter handle are a coherent group worth a dedicated module that the build-time Vite plugin can import without dragging in the entire site config.

Consequences

What gets easier

  • A brand-narrative change (e.g. "350M → 5B+", "295 → 410 segments") is a one-file edit in seo.js. Both runtime Helmet and build-time HTML pick it up automatically.
  • New social-preview surfaces (LinkedIn, Slack unfurls, iMessage) get the right copy on first deploy without anyone remembering to update index.html.
  • Future mkdocs build --strict-style guard: a CI lint can grep index.html for any hardcoded numerical claim ("M MAIDs", "B+", "segments") and fail the build, since correct content is always placeholders.

What gets harder / what we give up

  • One more file to remember (seo.js). Mitigated by index.html having a comment block: <!-- Site-default SEO meta lives in src/content/seo.js, injected at build time via vite.config.js -->.
  • index.html stops being directly previewable in a browser without a build step (placeholders aren't valid meta content). Not a real loss — we never preview unbuilt index.html anyway; npm run dev hits the Vite dev server which already does the substitution.
  • Vite plugin = additional surface area in the build pipeline. Bounded: ~25 lines, no external deps, fully testable.

What this commits us to (reversibility cost)

  • Anyone editing the site-default meta now goes through seo.js. ~5 minutes of onboarding. Reversibility cost is trivial (delete the plugin, hardcode index.html again).
  • Vite-specific. If we ever migrate off Vite (Astro, Next.js, Remix), the build-time injection layer needs porting — but every alternative has its own analogous mechanism, so this is not lock-in.

Risks & mitigations

  • Risk: build-time substitution silently fails (placeholder leaks to production HTML). Mitigation: post-build CI step greps dist/index.html for any <!--% substring and fails if found. Add to .github/workflows/deploy.yml.
  • Risk: developer adds a new meta tag to SEO.jsx and forgets to add it to seo.js / index.html. Mitigation: lint rule (or just a docstring at the top of seo.js listing every tag both surfaces emit). Not load-bearing — runtime Helmet wins for any tag it sets.
  • Risk: drift from messaging.js (which has its own taglineFull). Mitigation: seo.js imports taglineFull from messaging.js and uses it as the default siteDescription. Truly one source for the marketing number.
  • Drift incident #1: 2026-04-22 Snowflake footprint review surfaced 5B+ MAIDs; site copy updated piecemeal across messaging.js, Layout.jsx, schema.js, siteConfig.js but not index.html.
  • Drift incident #2: 2026-05-06 owner spot-checked huskydata.io and noticed hero (5B+) vs. meta (350M) mismatch. Fixed in commit 07ccd41.
  • react-helmet-async is already in dependencies and wired through main.jsx <HelmetProvider> — no new package needed for the runtime side.
  • Vite version ^7.2.4 supports transformIndexHtml as a stable plugin hook.
  • Existing canonical content modules: src/content/messaging.js, src/content/siteConfig.js, src/content/schema.js. seo.js slots in alongside.

Review

  • Next review: 2026-11-06 (six months)
  • Triggers to revisit early:
  • More than one route gains per-page OG cards that social platforms must see pre-JS — escalate to static pre-render
  • Migration off Vite to a framework with native head management (Next.js, Astro)
  • A third drift incident — would indicate the proposed mechanism failed to land or eroded