← Back to Blog

A Spanish page tagged lang=en: how the Mega Analyzer catches language tags that lie

· 13 min read A Spanish page tagged lang=en: how the Mega Analyzer catches language tags that lie

Three rows on the Mega Analyzer describe the same failure from three angles: the page tells machines it speaks one language while the humans reading it get another. html lang invalid or contradicts the page text (WCAG 3.1.1): declared "en", text reads as es is the A11y tab catching a Spanish page whose <html> tag claims English. Translation is a JS widget only (no crawlable language URLs) and N hreflang tag(s) malformed: en_US → /en/ live on the Multilingual + Discoverability card (Perf + AI tab) and catch the two ways a site tries to be bilingual without giving search engines a page they can index in the second language. None of the three reads your translations. They read the declaration and test whether it holds up.

What the check actually tests

Page language is rule 23 in the shared quick-check module at /js/wcag-quick-checks.js, the same file the keyboard and focus rows come from, run against the served HTML parsed into a document. It reads the lang attribute on <html> and does two things with it. First, the value has to fit the BCP 47 shape: a two or three letter language subtag, an optional four letter script, an optional region of two letters or three digits, and optional variants of five to eight characters, all joined by hyphens. en, en-US, es-MX, pt-BR and zh-Hant-TW pass. en_US fails because of the underscore, english fails because it is seven letters, and a fail here reads html lang invalid or contradicts the page text (WCAG 3.1.1): "en_US" is not BCP 47. The test is shape only. It never consults the IANA registry, so en-UK passes this row even though the registered region subtag for the United Kingdom is GB; the Hreflang Audit checks region codes against a list and does catch it.

Second, when the value is well formed, the rule reads the language of the text. It clones the body, strips script, style, noscript, template, code and pre, lowercases what is left and splits it into words of two or more letters (accented letters count as letters). If there are at least 120 words it counts hits against six stopword lists of twenty words each: English, Spanish, French, German, Portuguese and Italian. Then it compares the highest scoring language with the primary subtag of the declaration (the part before the first hyphen, so en-US is compared as en). The row fails when the winner is a different language, scored at least 25 hits, and scored at least 2.5 times the declared language's count (with the declared count floored at 1). That is the declared "en", text reads as es wording, with the detail "Screen readers pronounce the page in the declared language; a Spanish page tagged en is read with English phonetics."

What does not trip it matters as much. A missing lang belongs to a different rule (rule 1, HTML lang attribute missing (WCAG 3.1.1)) and to the SEO tab's HTML lang attribute row; rule 23 stays silent when there is no value at all. A page under 120 words is never scored, so a Spanish landing page that is mostly a hero image and a phone number passes on the attribute alone. A page declared in a language outside the six lists (nl, ja, ar) has no count of its own to compare against, so it fails only if one of the six languages reaches 25 hits outright. And a page that is truly half and half, English and Spanish paragraphs alternating, usually sits under the 2.5 ratio and passes, which is correct: WCAG says to declare the language used most. There is no pass row. When the rule is satisfied it simply does not appear in the Fails list, and the cross-audit pills under a fail are labeled Page language.

Translation widget only comes from the i18n block of the gap scanner inside the Mega Analyzer itself. It tests the raw HTML for the fingerprints of in-browser translators: translate.google.com/translate_a/element.js, the google_translate_element container id, TranslateElement, gtranslate, and weglot.com. If any of those is present and the page carries zero <link rel="alternate" hreflang> tags, the row warns with Translation is a JS widget only (no crawlable language URLs). If the widget is present but hreflang tags exist too, it renders as a pass, No JS-only translation widget (translated URLs with hreflang exist alongside the widget), on the reasoning that real language URLs exist and the widget is a convenience on top. A site with neither a widget nor hreflang gets nothing from this row at all.

hreflang well-formed reads every <link rel="alternate" hreflang> in the served HTML and appears only when there is at least one. Each tag is tested three ways: the hreflang value must match the same BCP 47 shape as above (plus the literal x-default), must contain no underscore, and its href must begin with http:// or https://. A tag that fails any of the three is listed as code, arrow, href, so en_US → /en/ fails on both the code and the relative path, and //example.com/es/ fails because a protocol-relative URL is not absolute. The row title is N hreflang tag(s) malformed: followed by up to three offenders. If every tag is clean, the rule then looks for a self-reference: it reduces each href and the page's canonical URL to origin plus path, drops any trailing slash, lowercases both, and requires at least one match. No match reads hreflang self-reference missing (no alternate matches the canonical). Only when both hurdles clear do you get hreflang tags well-formed (N, BCP 47, absolute, self-referencing). Two limits to know. The self-reference test needs a canonical on the page (with no canonical it cannot run, and it does not fail), and it looks at this one page only. Whether the Spanish page links back is a separate fetch, done by the reciprocity check on the Indexing Hygiene tab and, across a whole cluster, by the Hreflang Audit. hreflang delivered in the sitemap or in an HTTP Link header is invisible to this row, which reads the head and nothing else.

Why it matters

The lang attribute has one audience the analyzer cares about first: assistive technology. WCAG 3.1.1 Language of Page is a Level A criterion, the minimum conformance level, and its whole text is that the default human language of each web page can be programmatically determined. The Understanding document explains why in one sentence: screen readers can load the correct pronunciation rules when the page language is identified. A Spanish page tagged en is not skipped by a screen reader. It is read aloud with English pronunciation rules applied to Spanish words, which is hard to follow for the person who most needed it. A wrong value is arguably worse than no value: a missing attribute lets the reader fall back to its own default, while a wrong one asserts something false with confidence. The W3C's guidance on language declarations is blunt about the method: always use a language attribute on the html tag, add one on an element surrounding any content in another language, and do not rely on a meta Content-Language element to indicate the language of a page.

Google is the audience it is not for. Google's documentation on multi-regional sites says it uses the visible content of a page to determine its language and does not use code-level language information such as lang attributes or the URL. That is why the analyzer scores the text rather than trusting the attribute, and it is also why the widget row exists. A Google Translate style widget swaps the words in the visitor's browser after they pick a language from a dropdown; the URL does not change, and the HTML Google fetched still contains only the original language. There is no Spanish page to rank, no Spanish text for an AI answer engine to quote, and a customer who searches in Spanish for what you sell finds a competitor who published a real /es/ page. The same Google page recommends a single language for content and navigation on each page and advises against side-by-side translations, which is the model the 2.5 ratio in rule 23 is built around: one page, one main language.

hreflang is where the format rules stop being pedantry. Google's localized versions documentation says each language version must list itself as well as all other language versions, that if two pages don't both point to each other the tags will be ignored, and that alternate URLs must be fully-qualified including the transport method, so https://example.com/foo, not //example.com/foo or /foo. The language code is ISO 639-1 with an optional ISO 3166-1 Alpha 2 region, and you can't specify a country code by itself. One en_US, one relative href, or a missing self-reference does not cost you one tag; it costs you the cluster on that page, and the English and Spanish versions go back to competing with each other for the same query. The underscore form usually comes from the platform: POSIX style locale identifiers are written en_US, and a template that prints that setting straight into the head leaks it unchanged.

How to fix it

1. Set lang to the language the page is mostly in. For a Spanish page that is es, or es-MX if the region matters for the voice. Write it with a hyphen, lowercase language and uppercase region by convention (tags are case insensitive under RFC 5646, so es-mx is not wrong, just unusual). Do not copy the locale string out of your platform settings without looking at it; that is where en_US comes from.

<!-- Before: Spanish content, English declaration -->
<html lang="en">

<!-- After -->
<html lang="es">

2. Mark the passages that are in another language. WCAG 3.1.2 Language of Parts (Level AA) asks for the language of each passage or phrase to be programmatically determinable, with exceptions for proper names, technical terms, words of indeterminate language, and words that have become part of the surrounding vernacular. So a Spanish page with an English legal notice wraps the notice, not the brand name.

<html lang="es">
  <body>
    <h1>Habitaciones desde $79 la noche</h1>
    <p>Reserva en línea o llama al 208-555-0100.</p>
    <p lang="en">Rates are subject to Idaho lodging tax. Cancellation policy applies.</p>
  </body>
</html>

This is the shape the WCAG Fix Generator emits for 3.1.1 and 3.1.2: a root lang, then a lang on the wrapping element for real foreign-language phrases, not for words like "menu" that both languages share.

3. Replace the widget with real URLs. Give each language its own path, / for English and /es/ for Spanish (or the reverse if Spanish is primary), each with its own <title>, meta description and lang. The translated copy has to exist in the HTML the server sends. If a widget stays for convenience, the row passes once the hreflang tags exist, but the widget is no longer doing the work.

4. Add the hreflang block, identical on every page in the cluster, with a self-reference and an x-default. Paste your locale map into the Hreflang Generator as tag | absolute URL lines and it emits the head block below, plus the sitemap xhtml:link variant and the HTTP Link header form for PDFs. Every URL absolute, every code hyphenated, and the page itself in the list.

<link rel="canonical" href="https://example.com/es/">
<link rel="alternate" hreflang="en" href="https://example.com/">
<link rel="alternate" hreflang="es" href="https://example.com/es/">
<link rel="alternate" hreflang="x-default" href="https://example.com/">

The canonical on the Spanish page points at the Spanish page. Pointing it at the English "master" tells Google this page is a duplicate, which is the opposite of what hreflang is saying, and it is also what makes the analyzer's self-reference test fail: no alternate matches a canonical that names a different URL.

5. Verify from both ends. Re-run the Mega Analyzer and check that the A11y Fails list no longer carries the 3.1.1 row and the Multilingual card reads hreflang tags well-formed. Then run the Hreflang Audit on the English URL: it fetches every alternate, builds the matrix of who declares whom, and validates the codes against an ISO list, which is where en-UK finally gets caught. Then open the Spanish page with a screen reader (VoiceOver on a Mac or a phone is enough) and listen to the first sentence.

When to leave it alone

A single-language site with no widget and no hreflang gets nothing from the two Multilingual rows, and that is right. hreflang is only for the same content published in more than one language or locale; there is nothing to declare on a site that has one.

A short page in the "wrong" language is not a false positive to chase. Under 120 words the text rule does not run, so a Spanish contact page with a form and an address passes on the attribute. Fix the attribute anyway, since the rule's silence is a threshold, not an endorsement.

A page that is genuinely bilingual, alternating English and Spanish paragraphs, should keep lang set to whichever language is used most (WCAG says that when several are used equally, the first language used is the default) and wrap the other language's paragraphs. That page usually passes the ratio test already, and if it does not, the fix is the lang on the passages, not a different root value.

Some hosted translation services serve real translated URLs with hreflang alongside their script, and the widget row is written to pass exactly that case even when the script matches the regex. Read the row's wording before ripping a translation service out: it is the absence of crawlable language URLs that warns, not the presence of the script.

Finally, a valid tag the analyzer's shape test rejects is possible but rare: a four-character variant beginning with a digit, such as the 1996 orthography variant for German, is legal under RFC 5646 and fails the regex. If your lang value is that unusual, you already know more about language tags than the row does; leave it.

Fact-check notes and sources

  • Source: https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html establishes WCAG 3.1.1 Language of Page (Level A): the default human language of each web page can be programmatically determined; that screen readers can load the correct pronunciation rules when the language is identified; and that a page in several languages declares the language used most (or the first used, when equal). H57, the lang attribute on the html element, is the sufficient technique.
  • Source: https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html establishes WCAG 3.1.2 Language of Parts (Level AA): the human language of each passage or phrase can be programmatically determined, except proper names, technical terms, words of indeterminate language, and words that have become part of the surrounding vernacular.
  • Source: https://www.w3.org/TR/WCAG22/#cc1 establishes that Level A is the minimum level of conformance.
  • Source: https://www.rfc-editor.org/rfc/rfc5646 (BCP 47) establishes the tag syntax: subtags separated by a hyphen (U+002D); two-letter primary language subtags from ISO 639-1 and three-letter ones from the other ISO 639 parts; four-letter script subtags from ISO 15924; two-letter region subtags from ISO 3166-1 or three-digit UN M.49 codes; variants of five to eight characters or four characters starting with a digit; and that tags are case insensitive, with lowercase language, titlecase script and uppercase region as the convention.
  • Source: https://www.w3.org/International/questions/qa-html-language-declarations establishes the W3C guidance to always use a language attribute on the html tag, to add one on an element surrounding content in another language, to take values from the IANA Language Subtag Registry, and not to rely on a meta Content-Language element to indicate the language of a page.
  • Source: https://developers.google.com/search/docs/specialty/international/localized-versions establishes that each language version must list itself as well as all other language versions; that if two pages don't both point to each other the tags will be ignored; that alternate URLs must be fully-qualified including the transport method; that codes are ISO 639-1 language plus optional ISO 3166-1 Alpha 2 region, with no country code alone; and that x-default is the recommended fallback for unmatched languages.
  • Source: https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites establishes that Google uses the visible content of a page to determine its language and does not use code-level language information such as lang attributes or the URL, and recommends a single language for content and navigation on each page.

Related reading

If you run several sites for the same audience, the /es/ tree is a template decision you make once and roll out to every property, and The $100 Network is about making that kind of decision at the portfolio level instead of one site at a time.

This post is informational, not legal advice. Mentions of third parties are nominative fair use. No affiliation is implied.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026