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:
- Runtime (per-page) —
src/components/Shared/SEO.jsxis areact-helmet-asynccomponent that takestitle,description,image,pathas props and rewrites<head>after JS hydrates. Pages that need a custom title/description (Methodology, About, Insights posts) call<SEO …/>and override the defaults. - Build-time (site default) —
index.htmlships 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.jsximports defaults fromseo.js(currently mixessiteConfig.taglineand hardcoded fallbacks) - Build path: a small Vite
transformIndexHtmlplugin readsseo.jsat build time and substitutes<!--%META_DESCRIPTION%-->/<!--%OG_DESCRIPTION%-->/<!--%TWITTER_DESCRIPTION%-->placeholders inindex.htmlwith the values fromseo.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.jsandindex.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-htmlwith 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.jsmixes 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 grepindex.htmlfor 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 byindex.htmlhaving a comment block:<!-- Site-default SEO meta lives in src/content/seo.js, injected at build time via vite.config.js -->. index.htmlstops 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 devhits 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.htmlfor any<!--%substring and fails if found. Add to.github/workflows/deploy.yml. - Risk: developer adds a new meta tag to
SEO.jsxand forgets to add it toseo.js/index.html. Mitigation: lint rule (or just a docstring at the top ofseo.jslisting every tag both surfaces emit). Not load-bearing — runtime Helmet wins for any tag it sets. - Risk: drift from
messaging.js(which has its owntaglineFull). Mitigation:seo.jsimportstaglineFullfrommessaging.jsand uses it as the defaultsiteDescription. Truly one source for the marketing number.
Evidence / links¶
- Drift incident #1: 2026-04-22 Snowflake footprint review surfaced 5B+ MAIDs; site copy updated piecemeal across
messaging.js,Layout.jsx,schema.js,siteConfig.jsbut notindex.html. - Drift incident #2: 2026-05-06 owner spot-checked huskydata.io and noticed hero (
5B+) vs. meta (350M) mismatch. Fixed in commit07ccd41. react-helmet-asyncis already independenciesand wired throughmain.jsx<HelmetProvider>— no new package needed for the runtime side.- Vite version
^7.2.4supportstransformIndexHtmlas a stable plugin hook. - Existing canonical content modules:
src/content/messaging.js,src/content/siteConfig.js,src/content/schema.js.seo.jsslots 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