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(orcheerio). - 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.
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.
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.
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
| Measure | Before | After (6 months) |
|---|---|---|
| Metadata rule failures (both sites) | 1,052 | 0 |
| Pages sharing a title with another page | 61 | 0 |
| Search Console "Unparsable structured data" | 214 | 0 |
| Pages with FAQ markup not matching visible text | 17 | 0 |
| Missing social images | 305 | 0 |
| 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.
Related
- Parent: Testing Static Sites in CI — the whole suite.
- Adding JSON-LD Structured Data at Build Time — generating the markup these tests check.
- Canonical URLs and Duplicate Content on Static Sites — the canonical policy behind the rules.
- Snapshot Testing Build Output — broad change detection alongside specific rules.
- Validating HTML Output in CI — markup validity on the same output.
Filed underHow-tos