DEV Community

Jangwook Kim
Jangwook Kim

Posted on • Originally published at jangwook.net

I Authored Six of the Seven Places My Title Is Declared

Six of the seven places my site declares a title come from a single field in frontmatter. The seventh comes from the other 1,300 pages, and I had never looked at it.

When I finally pulled every internal link pointing at an article and read what was inside it, the longest one ran 367 characters.

A title is written once and published seven times

Start with the ground floor. On the web, "page title" isn't one value. It's a bundle of channels with different consumers, and each consumer reads a different declaration.

<title> feeds the browser tab, the bookmark, and the raw material for a search result. W3C's WCAG 2.2 makes it a Level A requirement under Success Criterion 2.4.2 Page Titled. The criterion reads "Web pages have titles that describe topic or purpose," and the Understanding document explains that user agents surface the title so people can identify the page, and that users with cognitive disabilities, limited short-term memory, or reading disabilities benefit from identifying content by its title. So the title is an accessibility requirement before it's ever an SEO asset.

<h1> is the visible heading, the top of the document outline, and the starting point for heading navigation in a screen reader. og:title is what social cards and messenger previews read. twitter:title is a variant of the same. The RSS <title> is what feed readers paint in a list. JSON-LD headline is a property of Article structured data.

One distinction is worth nailing down before going further. headline is not on Google's list of title link sources. Control your title links in search results names the content of <title> elements, the main visual title on the page, heading elements such as <h1>, the content of og:title meta tags, other content made large and prominent through styling, other text on the page, anchor text on the page, text within links pointing to the page, and WebSite structured data. headline is a recommended property of Article structured data, where the guidance is to keep titles concise because long ones may be truncated on some devices. That same page states plainly that "Google does not guarantee that features that consume structured data will show up in search results." Keeping headline in sync with <title> is consistency work. It is not a lever on what appears in search.

Then there's the last channel. The text of every link pointing at this page from somewhere else. It isn't in the page's own file. It's scattered across 1,300 other pages, and it's usually written by a component rather than a person.

Six channels agreed on every single page

The site publishes 324 posts per language, 1,296 across four languages. I parsed the whole dist/ output, pulled five channels from every article page, and matched them against the separately generated RSS feed.

// Pull every title channel from each built article page and compare as strings
const $ = cheerio.load(readFileSync(file, 'utf8'));
const rec = {
  title:    norm($('head > title').first().text()),
  h1:       norm($('h1').first().text()),
  ogTitle:  norm($('meta[property="og:title"]').attr('content')),
  twTitle:  norm($('meta[property="twitter:title"]').attr('content')),
  headline: null,
};
$('script[type="application/ld+json"]').each((_, el) => {
  const data = JSON.parse($(el).contents().text());
  const nodes = Array.isArray(data) ? data : (data['@graph'] ?? [data]);
  for (const n of nodes) if (n?.headline && !rec.headline) rec.headline = norm(n.headline);
});
Enter fullscreen mode Exit fullscreen mode

The result was boring in the best way. All 1,296 articles carried all five channels, and title == og:title, title == h1, title == headline, and h1 == RSS title each came back at 100%. Lengths held steady too: 13 characters minimum, median 46, p90 of 59, maximum 70. Ninety-seven titles run past 60 characters, but 60 is a community rule of thumb, not a documented Google limit. The official page gives no character count at all. Treat it as a reference number.

That perfect agreement isn't good architecture. It's simple architecture. All five channels render from one title field in frontmatter, so there's nowhere for drift to enter. Flip that around and the warning becomes obvious: if your CMS or SSR template reads a different field per channel, you will not see 100%. On that kind of site, this script finds something on day one.

Nobody was hand-writing the seventh channel

Here's where it stopped being boring. I collected the anchor text of all 18,296 internal links pointing at article pages and compared each one to the <h1> of the page it targets.

Measure Before
Internal links pointing at articles 18,296
Anchor text exactly equal to the target <h1> 121 (0.7%)
Contains the target <h1> but runs longer 5,185 (28.3%)
Neither 12,990 (71%)
Anchor length p90 / p99 / max 144 / 290 / 367 chars
Anchors over 150 characters 1,716

I opened the 367-character one. Date, reading time, title, description, three tags, and "Read more." It was the entire contents of one blog card, flattened into a single link.

The cause was one wrapper.

<!-- Before: the whole card is one link -->
<article class="post-card">
  <a href={href} class="post-card__link">
    <div class="post-card__media">
      <Image src={heroImage} alt={title} ... />
    </div>
    <div class="post-card__body">
      <time>{date}</time> · <span>{readingTime} min</span>
      <h3>{title}</h3>
      <p>{description}</p>
      <div class="post-card__tags">...</div>
      <span>{readMoreLabel}</span>
    </div>
  </a>
</article>

Enter fullscreen mode Exit fullscreen mode

This pattern is everywhere, because it's the cheapest way to make a whole card clickable. The bill arrives in three places at once.

Accessibility first. A link's accessible name is computed by concatenating the text it contains. My thumbnail also carried the title as its alt, so the title landed in the name twice. A screen reader user tabbing through a list of links hears one item read out as a 367-character paragraph. I've written before about how accessible names get computed; that time I was looking at buttons, and this time the same algorithm bit me from the other direction.

Second, this is the text Google says it consults. The documentation notes that when an issue is detected on a page, it may try to generate an improved title link from anchors, on-page text, or other sources. Every article on my site was pointing at every other article with a blob of date plus title plus description plus tags.

Third, everything that reads the link graph. For a crawler that parses HTML and never runs JavaScript, anchor text is close to the only signal describing how two documents relate. I'd measured that AI crawlers don't render your JavaScript and had been careful about that channel ever since, while my own cards were flattening it.

Wrap the link around the title, then take the click target back

The fix is well known. The anchor wraps the title text only, and a pseudo-element restores the click area you just gave up.

<article class="post-card">
  <div class="post-card__media" aria-hidden="true">
    <Image src={heroImage} alt="" ... />
  </div>
  <div class="post-card__body">
    <time>{date}</time> · <span>{readingTime} min</span>
    <h3><a href={href} class="post-card__link">{title}</a></h3>
    <p>{description}</p>
    <div class="post-card__tags">...</div>
    <span class="post-card__read" aria-hidden="true">{readMoreLabel}</span>
  </div>
</article>

<style>
  .post-card { position: relative; }

  /* The link wraps the title. The overlay carries the card-wide click target. */
  .post-card__link::after {
    content: '';
    position: absolute;
    inset: 0;
  }

  .post-card__link:focus-visible {
    outline: 2px solid var(--flow-deep);
    outline-offset: 3px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Three details travel with the change. Set the thumbnail alt to an empty string, since the title link already states the destination and there's no reason to announce it twice. Give decorative text like "Read more" an aria-hidden="true". And move your hover selectors from .post-card__link:hover to .post-card:hover, or the card goes dead under the cursor.

Declare the focus ring explicitly too. The link just shrank to the size of a heading, so the default focus indicator no longer reads as clearly inside the card.

The overlay has an honest cost. Body text underneath inset: 0 becomes awkward to drag-select with a mouse. If people need to copy the blurb out of your card, reconsider the pattern. On a blog index I decided that almost never happens.

I applied the same change in three places: the blog index card, the related-posts list at the foot of every article, and the latest-post cards on the multilingual landing page. The related-posts list was worse than the index, because each item carries a recommendation sentence under the title, and the anchor was eating that too.

Fixed it, then measured again

Same script, same corpus. The link count is identical at 18,296. What changed is what's inside them.

Anchor text length Before After
1-30 chars 11,792 12,294
31-70 chars 2,132 5,947
71-150 chars 2,656 55
151-300 chars 1,605 0
Over 300 chars 111 0
p90 / p99 / max 144 / 290 / 367 52 / 67 / 111
Exactly equal to target <h1> 121 (0.7%) 5,285 (28.9%)

Anchor text length distribution before and after. The 1,716 links running past 150 characters dropped to zero, and anchors exactly matching the target title went from 121 to 5,285.

I checked it in a real browser as well. Headless Chromium via Playwright, viewport 1100 pixels wide: the first card measures 1036×329, contains exactly one anchor, and document.elementFromPoint at 75% across and 80% down the card box, out in the tag row, resolves to the link. The overlay is holding the click target. Layout came through untouched.

The blog index card after the change. Same two-column grid, same spacing, with the link now wrapping a single line of title text.

One number in that table invites a bad conclusion. Only 28.9% of anchors exactly match the target <h1>, so 71% still differ. That isn't a defect. Split it by the component that generated each link and the picture resolves.

12,990 anchors that differ from the title Count Verdict
Header language switcher 5,184 Correct. "KO 한국어" names the destination
Article-page language switcher 3,888 Correct. Points at the same post in another language
Contextual links inside post bodies 3,846 Correct. Should describe the target in the sentence's own terms
Everything else 72 Lists and navigation

Forcing contextual body links to repeat the target's title would make them worse, not better. Inside a sentence, "AI crawlers don't render your JavaScript" reads better as a phrase shaped for the reader. Language switchers are the same story, and they're the structural false positive I separated out during the trailing-slash audit. The goal was never "every anchor should be a title." It's "don't say something other than the title in the place that's supposed to say the title."

Fifty-five anchors still run past 70 characters after the fix. All of them are contextual links I wrote by hand inside post bodies. Not one is component output.

What to watch, channel by channel

Sorting the seven channels by who reads them and who writes them makes the decisions easy.

Channel Primary consumer Who writes it Common failure What to do
<title> Browser, search, assistive tech Human, via template Empty, duplicated, site name repeated WCAG 2.4.2 Level A. Unique per page
<h1> Screen, heading navigation Human Missing, or several per page Exactly one, matching the visible title
og:title Social and messenger previews Template Drifts away from title Render from the same source
twitter:title Some clients Template Left stale Mirror og:title or omit it
JSON-LD headline Structured data consumers Template Mistaken for a ranking lever Recommended property. Concise and consistent
RSS <title> Feed readers Feed generator Out of step with the post heading Render from the same field
Inbound anchor text Search, crawlers, screen readers Components Swallows the whole card, runs long Link the title only, restore clicks with an overlay

Only the last row is genuinely dangerous. The first six sit in one file, visible to the eye, and a wrong value looks wrong. The last one is written down nowhere. Until you fix the component, it isn't even counted.

From a team perspective this is a textbook shape for technical debt. Seven declaration points, six of them in review, and the seventh changes only as a side effect of UI work. A ticket that says "make the whole card clickable" has no reason to pass through an SEO or accessibility review. Which is exactly why this class of problem needs a gate rather than someone's attention.

A title in the wrong script is a documented rewrite trigger

The same sweep let me check WCAG 2.4.2 across all 1,336 pages. One page had an empty <title>: an ad network ownership-verification stub sitting in public/. It isn't a content page, so counting it as an accessibility failure would be a stretch, but an HTML file with no title is shipping in my build output and that's worth writing down.

The duplicates mattered more. Two groups of pages shared a byte-identical <title>, four pages in total.

  • en/iterative-review-cycle-methodology had an English body with a Korean <title> and description
  • ko/barracuda-cuda-amd-compiler had a Korean body with a Japanese <title>

Google's title link documentation lists a mismatch between the title and the page's primary language or writing system among the reasons it may rewrite a title link. My pages landed squarely in a case the documentation names. On a site where four language versions move as one set, that drift is unsurprising: copy one version's frontmatter, forget to swap the title, and it survives. Both are fixed, and duplicate title groups are now zero.

I checked the extraction side too. Feed an article page to Readability 0.6.0 and it returns a title identical to the <h1>. Convert the <body> alone to text and the first line is the skip link, the second is header navigation, and no title appears until the <h1> shows up. For any pipeline that scrapes body text only, <h1> is effectively the sole title signal.

Where my evidence stops

Some honest boundaries.

I measured what my site emits, not what Google does with it. I have not observed a title link being rewritten for any of my pages, and I hold no before-and-after impression data. I'm not claiming that aligning anchor text with titles moves rankings. The official documentation does not connect title links to ranking, and it explicitly declines to guarantee that structured-data features will appear in search results at all.

The 60-character title budget is convention, not an official number. Ninety-seven of my posts exceed it, and I don't consider that a reason to rewrite them.

The accessibility side is not ambiguous. A 367-character link name is bad design under any reading. A link that recites an entire card where a title belongs is uncomfortable for a person first, whatever a search engine decides to do with it. That was reason enough for me.

Checklist: four counts you can run today

Run these against build output. Not the dev server, the dist/ directory. Half the problem never appears in source, because components create it.

  1. Channel diff. Pull title, h1, og:title, JSON-LD headline, and the RSS title per page and compare them as strings. Any mismatch fails.
  2. Anchor length ceiling. Count anchor text over your threshold (mine is 70 characters) pointing at content pages. Fails if it's component output, passes if it's a contextual link in body copy.
  3. Title uniqueness. Empty <title>, or the same title on two or more pages, fails. This is where WCAG 2.4.2 Level A lands.
  4. Title language match. Check that the writing system of <title> matches html[lang]. On a multilingual site, this one catches the most.

The skeleton of the gate is short.

const fails = [];
for (const page of articles) {
  if (page.title !== page.h1) fails.push(`h1 drift: ${page.path}`);
  if (page.title !== page.ogTitle) fails.push(`og:title drift: ${page.path}`);
  if (page.title !== page.headline) fails.push(`headline drift: ${page.path}`);
}
for (const a of componentAnchors) {
  if (a.len > 70) fails.push(`anchor ${a.len} chars: ${a.from} -> ${a.to}`);
}
if (fails.length) { console.error(fails.join('\n')); process.exit(1); }
Enter fullscreen mode Exit fullscreen mode

How you wrap one card decides the link text of an entire site. It took me 1,296 posts to notice. If you're running a site where nobody has ever counted the text your components generate, the numbers are probably close to mine. Anyone who wants to open theirs up together can reach me through the contact path on my profile.


Sources: Google Search Central, Control your title links in search results and Article (Article, NewsArticle, BlogPosting) structured data; W3C WAI, Understanding SC 2.4.2: Page Titled. Measurement setup: my own Astro build output, 1,336-1,338 HTML pages, 1,296 article pages, parsed in full with Node 22.22 and cheerio 1.2.0. Browser verification used Playwright Chromium (headless, 1100px viewport); extractor checks used @mozilla/readability 0.6.0 and html-to-text 10.0.0. Every figure comes from this site and this build, and none of it is a statement about how Google processes anything.

Top comments (0)