Validating HTML Output in CI
Static site generators turn templates into HTML, and templates are code that nobody lints for its output. A partial that forgets to close a <div> inside a conditional, a Markdown shortcode that nests a <p> inside a <p>, two components that both render id="overview" on the same page — none of these look wrong in a browser, because browsers repair broken HTML silently. They do show up elsewhere: an in-page link jumps to the wrong heading, a screen reader announces the sidebar as part of the article, a search indexer indexes the footer as body text, a feed reader truncates a post.
This guide validates every built page with html-validate in CI, picks rules that matter for static sites, handles third-party and generated markup, and introduces the check on an existing site without a wall of failures. It is part of Testing Static Sites in CI. The numbers come from a 2,300-page Hugo documentation site.
Prerequisites
- A built site in
dist/(orpublic/for Hugo). - Node 20+ and
html-validate8 or newer. - Agreement on which rule set to enforce — the recommended preset is a good start.
Step 1: Run It Once and Look
npx html-validate "public/**/*.html" --formatter summary
The first run on the documentation site reported 18,420 errors across 2,300 pages — a number that sounds catastrophic and was mostly a handful of template bugs repeated on every page. Grouping by rule told the real story:
| Rule | Errors | Distinct causes |
|---|---|---|
no-dup-id | 9,200 | 2 (two partials both render id="toc" and id="search") |
element-permitted-content | 4,610 | 3 (block elements inside <p> from shortcodes) |
close-order | 2,300 | 1 (unclosed <div> in the footer partial) |
wcag/h37 (img without alt) | 1,140 | 38 content images + 1 theme logo |
prefer-native-element | 870 | 1 (role="button" on a span) |
| other | 300 | various |
Nine template fixes removed 17,900 of the 18,420 errors.
Step 2: Configure Rules for a Static Site
Start from the recommended preset and adjust for what static sites produce:
{
"extends": ["html-validate:recommended", "html-validate:a11y"],
"rules": {
"no-inline-style": "off",
"long-title": ["error", { "maxlength": 70 }],
"no-trailing-whitespace": "off",
"void-style": ["error", { "style": "omit" }],
"attribute-boolean-style": "off",
"no-redundant-role": "warn",
"require-sri": ["error", { "target": "crossorigin" }],
"valid-id": ["error", { "relaxed": true }]
},
"elements": ["html5"]
}
no-inline-style is off because inline SVG diagrams and critical CSS legitimately use style. void-style matches what the minifier outputs so the two tools do not fight. require-sri enforces Subresource Integrity on cross-origin scripts, which pairs with Subresource Integrity for Third-Party Assets. valid-id in relaxed mode allows IDs generated from headings that start with digits.
Step 3: Ignore What You Do Not Own
Some markup is not yours to fix: embedded third-party widgets, HTML fragments from an API reference generator, legacy pages kept for URLs. Exclude them narrowly rather than weakening rules everywhere:
{
"overrides": [
{ "files": ["public/api/legacy/**"], "rules": { "element-permitted-content": "off" } }
]
}
And inside templates, wrap unavoidable third-party snippets with directives:
<!-- [html-validate-disable-block attribute-allowed-values: vendor embed code] -->
<div class="vendor-widget" data-config='…'></div>
Every disable carries a reason, and a weekly report counts them, so exclusions stay visible.
Step 4: Baseline, Then Ratchet
After the template fixes, 520 errors remained, mostly content images without alt text that needed a writer to supply real descriptions. Failing every pull request on those would have blocked unrelated work. Instead, save the current results as a baseline and fail only on new errors:
npx html-validate "public/**/*.html" --formatter json > current.json
node scripts/compare-html-baseline.mjs baseline.json current.json # exits 1 on new rule+file+selector
The comparison keys each error by rule, file and selector, so moving content does not produce false "new" errors. Fixes remove baseline entries in the same pull request. The documentation site's baseline went from 520 entries to 12 in four months.
Step 5: Wire It Into CI
Validation runs on the build output, needs no server, and finishes in under a minute, so it runs on every pull request right after the build:
- run: hugo --minify
- run: npx html-validate "public/**/*.html" --formatter json > html.json || true
- run: node scripts/compare-html-baseline.mjs tests/html-baseline.json html.json
- if: failure()
run: node scripts/html-summary.mjs html.json >> "$GITHUB_STEP_SUMMARY"
On large sites, validating one page per template plus the changed pages cuts time to seconds; a nightly job validates everything. On this site the full run took 38 s and the sampled run 14 s.
Validation and Minification Together
Run validation on the same output that deploys. If HTML is minified in production, validate the minified output — minifiers occasionally produce invalid markup (a removed closing tag that changes nesting, an unquoted attribute value containing a special character), and that is exactly the kind of bug worth catching. The html-validate void-style and attribute-boolean-style rules should then match what the minifier produces, as configured above. If validation runs on unminified output for readability of error messages, also run a smaller check on the minified output for parse errors only.
The same logic applies to anything that post-processes HTML after the generator: critical CSS inlining, search indexers that inject attributes, CSP hash injection. Validate after the last transform.
Errors Worth Explaining to Writers
Some validation errors come from content rather than templates, and writers need to understand them. The three most common on the documentation site were raw HTML in Markdown with an unclosed tag (usually a <br> written as <br> in a table cell, which is fine, next to a <div> that was not), images without alt text, and headings used for styling that skipped levels. Each rule's error message was mapped to a short explanation in the CI summary — "Image on line 42 has no alt text; describe what the image shows, or use an empty alt if it is decorative" — which cut the number of "what does this error mean" questions in pull requests to almost none.
Validation messages are also useful at authoring time: the html-validate VS Code extension runs the same configuration against built files, so writers who preview locally see the same errors before they push.
A last practical point: validate the 404 page and any page generated outside the normal templates — search results shells, offline pages for a service worker, redirect stub pages. They are rendered by different code paths, are rarely looked at, and on this site they held three of the nine template bugs found on the first run.
Measured Impact
| Measure | Before | After (4 months) |
|---|---|---|
| Validation errors, full site | 18,420 | 12 (baseline) |
| Pages with duplicate IDs | 2,300 | 0 |
| Broken in-page anchor links (from duplicate IDs) | 214 | 0 |
| axe violations traced to invalid nesting | 31 | 0 |
| New errors merged | — | 0 |
| CI time added (sampled run on PRs) | — | 14 s |
Pitfalls & Rollback
- Validating source templates instead of output. Template languages are not HTML; validate what ships.
- Blanket rule disables. Turning off
element-permitted-contentglobally hides real template bugs. - Baselines keyed on line numbers. Minified output has one line; key on selectors.
- Validating before post-processing. Transforms after validation can introduce errors.
- Rollback: validation is a CI step; removing it or making it non-blocking has no effect on the site.
Conclusion
HTML validation on a static site is mostly a template review that runs itself. The first run looks alarming because each template bug repeats on every page; a handful of fixes clears almost all of it. Configure rules for what static sites produce, exclude narrowly, baseline the rest and fail only on new errors — in 14 seconds per pull request.
FAQ
Why validate HTML if browsers render invalid markup anyway?
Browsers repair invalid HTML silently, but each browser, screen reader, search indexer and feed reader may repair it differently. Unclosed elements, duplicate IDs and invalid nesting cause broken anchors, wrong accessibility trees and mis-parsed content in tools other than the browser you tested with.
Should I use the W3C Nu validator or html-validate?
html-validate runs locally in Node, is fast enough for thousands of pages and has configurable rules, which suits CI. The Nu HTML Checker is the reference implementation of the specification and is useful for occasional deep checks or disputes, but running it over a large site on every pull request is slow.
Which errors matter most on static sites?
Duplicate IDs, which break in-page links and ARIA references; unclosed or misnested elements from templates, which change the document structure; interactive elements nested inside each other, such as links inside buttons; and missing required attributes like alt on images and lang on the html element.
How fast is html-validate on a large site?
On a 2,300-page documentation site it validated every page in about 38 seconds on a standard GitHub Actions runner, and about 14 seconds when limited to one page per template plus changed pages.
Related
- Parent: Testing Static Sites in CI — the full suite.
- Accessibility Testing with axe and pa11y in CI — the accessibility layer above valid markup.
- Snapshot Testing Build Output — catching output changes beyond validity.
- Minifying HTML, CSS and JS in Eleventy — the minifier settings validation must match.
- Checking Links in Pull Requests — anchors that duplicate IDs break.
Filed underHow-tos