Months after I changed the landing headline, the title in search results was still the old one. So was the preview card when I pasted the link into a chat app. But opening that page in a browser showed the new headline, perfectly fine.
If your search preview doesn't match what's actually on screen, what do you check first? I started with the sitemap and the canonical tag. Both were clean.
The strange combination
- Browser: new headline.
- HTML from
curl: old headline. - Google Search Console's indexed-page preview: old headline.
- OG preview: old headline.
Only the browser saw the new headline. Everything else reads the HTML without running JavaScript. That narrows it: the new headline is drawn by JS, and the thing baked into the HTML is the old one.
What was baked into the HTML
The landing component looked like this:
// server component
export default function Landing() {
return <h1>Name Yourself, Again</h1> // four-month-old copy, untouched
}// client component
"use client"
export function Headline() {
const t = useCopy(locale) // latest copy from the locale data
return <h1>{t.landing.headline}</h1>
}When I updated the copy, I only edited the locale data (copy/en.ts). The hardcoded <h1> in the server component stayed. So:
- Initial HTML (what the server ships): the hardcoded old
<h1>. - After hydration: the client swaps in the new
<h1>from the locale data.
To a human eye the swap is instant and invisible. Crawlers, curl, and OG scrapers take the pre-swap HTML, and Google's rendering queue defers JS execution, so that old headline settles into the index.
The same pattern was in several live apps. All of them had "fallback hardcoded, real value in a data object," and the copy edit touched only one side.
What would you do here?
Two branches:
- Hand-sync the server component's hardcoded
<h1>to the latest copy. - Make server, client, and OG all read the copy from one place.
Option 1 is right today and drifts again on the next edit. Any structure where a human keeps two places in sync will eventually fall out of sync. I went with option 2.
The fixed structure
// server component — read the locale data on the server and render it directly
import { getCopy } from "@/copy"
export default async function Landing({ params }: { params: { locale: string } }) {
const t = getCopy(params.locale)
return <h1>{t.landing.headline}</h1> // the latest copy is baked into the HTML
}generateMetadata uses the same getCopy. Now the copy lives only in copy/<locale>.ts, and the HTML, the meta tags, and the client all come from there. The hardcoded <h1> is deleted. If a fallback is genuinely needed, it imports getCopy's default locale too — not a copy-pasted string.
Three self-checks
- Does
curl -s https://yourapp/ | grep '<h1'match the title you see in the browser? - How many places is your marketing copy in the codebase? (
grep -rn "part of that phrase" src/) Two or more, and it's already primed to drift. - When you change the copy in one place, do the SSR HTML, the OG tags, and the client screen all change — or just one of them?
The honest part
This is less a bug than a structure that made lying easy. The hardcoded fallback went in with good intentions — "in case the data fails to load" — but what it actually did was feed crawlers the old headline. When the fallback and the real value live in different files, they will drift.
One rule: if the same string appears twice in the code, assume one copy is already wrong and collapse it to one.
Same family as The Repo Was a Generation Behind the Store and Cloudflare's Cache Was Holding an Old Page. In all three, "what I see" and "what the outside sees" had split, and the split was always at the seam I trusted myself to keep in sync.
Do one thing now: curl your own landing page and read the <h1> with your own eyes.