Testing Structured Data and Meta Tags

Metadata is the part of a static site that only machines read, which is why it breaks without anyone noticing. A layout refactor removes the meta description from section pages. A new content type has no og:image, so every share of it shows a blank card. A template falls back to the site name for untitled pages, and forty tag archives share one title. A JSON-LD block concatenated from strings becomes invalid for every page whose title contains a quotation mark. Search Console reports some of this weeks later; social cards and feed readers report none of it.

This guide checks metadata and structured data on every built page in CI with one script over the build output: per-page rules, cross-page duplicate detection, JSON-LD parsing and property checks, and a comparison of FAQ markup with the visible FAQ. It is part of Testing Static Sites in CI, and it tests the output described in Technical SEO for Static Sites. The numbers come from a 2,300-page Hugo documentation site and a 410-page Astro site.

Prerequisites

  • A built site and its production origin.
  • Node 20+ and node-html-parser (or cheerio).
  • A list of intentional exceptions, such as pages that deliberately canonicalize elsewhere.

Step 1: Per-Page Rules

Each rule is a small function that receives the parsed page and its expected URL and returns problems. Keeping rules as data makes them easy to review and extend:

// tests/meta/rules.mjs
export const rules = [
  ['one <title>', (d) => d.qa('head title').length === 1],
  ['title 10–65 chars', (d) => between(d.q('title')?.text.trim().length, 10, 65)],
  ['one meta description', (d) => d.qa('meta[name=description]').length === 1],
  ['description 70–160 chars', (d) => between(d.attr('meta[name=description]', 'content')?.length, 70, 160)],
  ['one canonical', (d) => d.qa('link[rel=canonical]').length === 1],
  ['canonical is own URL', (d, url) => d.attr('link[rel=canonical]', 'href') === url || EXCEPTIONS.has(url)],
  ['og:url equals canonical', (d) => d.attr('meta[property="og:url"]', 'content') === d.attr('link[rel=canonical]', 'href')],
  ['og:image absolute', (d) => /^https:\/\//.test(d.attr('meta[property="og:image"]', 'content') ?? '')],
  ['html has lang', (d) => !!d.attr('html', 'lang')],
  ['one h1', (d) => d.qa('h1').length === 1],
  ['no noindex in production', (d) => !/noindex/.test(d.attr('meta[name=robots]', 'content') ?? '')]
];

The runner parses every HTML file once and applies every rule, printing failures with the page path and rule name. On 2,300 pages it took 7 s.

Metadata rule failures on the first run Horizontal bars of failures on the first run across both sites. Description missing or too short 412. og:image missing 305. Title too long 188. More than one h1 94. Canonical not equal to own URL 41. Missing lang 12. First-run failures by rule Description missing or short 412 og:image missing 305 Title too long 188 More than one h1 94 Canonical not own URL 41 Missing lang 12
As with HTML validation, most failures traced back to a few templates rather than to individual pages.

Step 2: Cross-Page Duplicates

Some problems only exist across pages. Collect titles, descriptions and canonicals into maps and report values shared by more than one page:

const byTitle = new Map(), byDesc = new Map(), byCanonical = new Map();
for (const { url, doc } of pages) {
  push(byTitle, doc.q('title').text.trim(), url);
  push(byDesc, doc.attr('meta[name=description]', 'content'), url);
  push(byCanonical, doc.attr('link[rel=canonical]', 'href'), url);
}
for (const [name, map] of [['title', byTitle], ['description', byDesc], ['canonical', byCanonical]]) {
  for (const [value, urls] of map) if (urls.length > 1 && !ALLOWED_DUPES.has(value)) {
    fail(`${urls.length} pages share ${name} "${value.slice(0, 60)}": ${urls.slice(0, 4).join(', ')}`);
  }
}

The first run found 43 tag pages sharing the title "Tags | Example Docs" (the tag name was missing from the template), 18 API pages with an identical description copied from a generator default, and 6 pairs of pages with the same canonical — all six were old pages whose canonical had been hard-coded in frontmatter before a move.

Step 3: JSON-LD Validity and Properties

Every application/ld+json block must parse, and each typed node needs its required properties with sensible values:

const REQUIRED = {
  TechArticle: ['headline', 'datePublished', 'dateModified', 'mainEntityOfPage'],
  BreadcrumbList: ['itemListElement'],
  FAQPage: ['mainEntity'],
  Organization: ['name', 'url']
};
for (const block of doc.qa('script[type="application/ld+json"]')) {
  let data;
  try { data = JSON.parse(block.text); } catch (e) { fail(`${url}: invalid JSON-LD (${e.message})`); continue; }
  for (const node of data['@graph'] ?? [data]) {
    for (const p of REQUIRED[node['@type']] ?? []) if (node[p] == null || node[p] === '') fail(`${url}: ${node['@type']}.${p} missing`);
    if (node.headline && node.headline.length > 110) fail(`${url}: headline over 110 chars`);
    if (node.datePublished && isNaN(Date.parse(node.datePublished))) fail(`${url}: bad datePublished`);
    if (node.mainEntityOfPage && node.mainEntityOfPage !== canonicalOf(doc)) fail(`${url}: mainEntityOfPage != canonical`);
  }
}

Breadcrumb items get an extra check: each item URL must exist in the build. That caught breadcrumbs pointing at a section index that had been renamed.

Structure of the metadata test script The script parses every built page once. The first pass applies per-page rules for title, description, canonical, Open Graph, lang and h1. The second pass collects values into maps and reports cross-page duplicates. The third pass parses JSON-LD, checks required properties and compares FAQ and breadcrumb data with the visible page. Three passes over the same parsed pages Parse every page once node-html-parser 2,300 pages in 3.1 s Per-page rules 11 rules, data-driven 1.2 s Cross-page duplicates title, description, canonical 0.4 s JSON-LD and visible text properties, FAQ, breadcrumbs 2.3 s
Parsing once and running three passes kept the whole script to 7 seconds on 2,300 pages.

Step 4: Markup Must Match the Page

Structured data that differs from visible content is a guideline violation and a sign of drift. The most common case is a FAQ: questions live in frontmatter for JSON-LD and in the body for readers, and someone edits one. Compare them:

const faq = graph.find((n) => n['@type'] === 'FAQPage');
if (faq) {
  const visible = doc.qa('#faq h3, .faq summary').map((e) => norm(e.text));
  const marked = faq.mainEntity.map((q) => norm(q.name));
  const missing = marked.filter((q) => !visible.includes(q));
  if (missing.length) fail(`${url}: FAQ markup not visible: ${missing[0]}`);
}

The same approach compares BreadcrumbList names with the visible breadcrumb trail. On the documentation site, the FAQ comparison found 17 mismatches on its first run — questions reworded in the body after the frontmatter was written.

Step 5: Open Graph Images Exist

og:image URLs are absolute, so a link checker that only checks internal relative links skips them. Map each og:image back to a file in the build (strip the origin) and assert it exists and is at least 1200 pixels wide by reading its header with image-size. Generated social images, as in Generating Open Graph Images at Build Time, occasionally fail silently for pages with unusual characters in titles; this check found 9 missing images on the Astro site in a year.

Metadata rule failures on the main branch per month Vertical bars of failures on the main branch at the start of each month. Month one 1,052 on the first run. Month two 214 after template fixes. Month three 61. Month four 18. Month five 3. Month six 0, with new failures blocked on pull requests throughout. Metadata failures on main over six months 1,052 Month 1 214 Month 2 61 Month 3 18 Month 4 3 Month 5 0 Month 6
The rules ran as a report for the first month and as a required check from month two.

Rolling It Out

Run the script as a report for a few weeks before making it required. The first run on an existing site is dominated by template-level problems that a few fixes remove, and the many remaining per-page content issues (short descriptions, long titles) needs writers' time. A report posted to $GITHUB_STEP_SUMMARY with counts per rule and examples gives writers a work list; making the check required once the count is near zero stops it growing again. Content-level rules such as description length can stay warnings for writers' pull requests, while structural rules — one canonical, valid JSON-LD, no noindex in production — are required from the start, because a violation there affects the whole site.

Keep the rules file next to the templates it tests and review changes to both together. When a new content type or layout is added, its pull request should add or adjust rules in the same change, so the metadata contract for that page type is written down in the one place that enforces it.

Measured Impact

MeasureBeforeAfter (6 months)
Metadata rule failures (both sites)1,0520
Pages sharing a title with another page610
Search Console "Unparsable structured data"2140
Pages with FAQ markup not matching visible text170
Missing social images3050
Check time per pull request—7 s (Hugo), 2 s (Astro)

Pitfalls & Rollback

  • Testing the source frontmatter. Templates decide what reaches the HTML; test the output.
  • Hard failures on content rules from day one. Start as a report, then ratchet.
  • Ignoring generated pages. Tag, archive and 404 pages are where duplicates live.
  • Absolute URL checks with a preview origin. Compare against the configured production origin, not the build environment's.
  • Rollback: the script is a CI step; removing it changes nothing on the site.

Conclusion

Metadata fails silently, so test it deliberately: per-page rules, cross-page duplicates, JSON-LD properties, and a comparison of structured data with what readers see — all in one pass over the built HTML. On these sites that took metadata failures from over a thousand to zero in six months, and the required check has kept them there.

FAQ

Which meta tags should be tested on every page?

Exactly one title and one meta description within length limits, exactly one canonical link equal to the page's own URL, Open Graph title, description, url and image, a lang attribute on the html element, a viewport meta tag, and robots directives appropriate for the environment.

How do I detect duplicate titles and descriptions?

Collect the title and description of every built page into a map and report any value used by more than one page. Duplicates usually come from templates that fall back to the site name when a page has no title, or from generated pages such as tag archives.

Can I test structured data without calling Google's Rich Results Test?

Yes. Parse each JSON-LD block, check it is valid JSON, and check required and recommended properties per type with a small schema. The online tools are useful for spot checks, but a local script can check thousands of pages in seconds on every pull request.

How do I check that FAQ structured data matches the visible FAQ?

Extract the questions and answers from the FAQPage JSON-LD and from the rendered FAQ section, normalize whitespace, and compare them. Any difference means one of them was edited without the other.

Filed underHow-tos