dotvitals

SEO Checker

Check one page's indexability, canonical, metadata, headings, structured data, robots.txt and sitemap.

Try

How to fix these

One section per finding above. There is nothing to copy here — these are changes in your own configuration, so each one names where the change is made, what it can break, and how to check it worked.

Let search engines index the page

A `noindex` directive removes the page from search results completely, and no amount of content, links or speed work changes that while the directive is there. It is the commonest cause of a page that will not rank, and it is usually an accident — a staging setting that shipped, or a CDN rule applied to a wider path than intended. If the exclusion is deliberate, nothing here needs doing; we cannot tell intent from the outside, which is why we report the directive rather than assume it is a mistake.

Who makes this change: You — this is a change on your own site. The directive is emitted by your own site — by the page's markup, by your CMS, or by a rule in the web server or CDN in front of it. No search engine, registrar or DNS host can remove it for you.

WordPress — the CMS setting or an SEO plugin emits it

Where: Settings → Reading in wp-admin, and then the per-post SEO panel of Yoast SEO, Rank Math or whichever SEO plugin is installed.

  1. Open Settings → Reading and check 'Discourage search engines from indexing this site'. If it is ticked, that one checkbox is putting `noindex` on every page of the site; untick it and save.
  2. If it was already unticked, the directive is per-page. Open the page in the editor and find the SEO plugin's advanced panel — Yoast calls it 'Advanced → Allow search engines to show this page in search results?', Rank Math 'Advanced → Robots Meta'.
  3. Check the plugin's site-wide content-type settings too: an SEO plugin can set `noindex` for a whole post type, archive or taxonomy, which looks per-page from the outside.
  4. Deactivate any second SEO plugin. Two plugins both managing robots meta is a reliable way to have one of them re-add what you just removed.

If it goes wrong: Re-ticking the setting or restoring the plugin's value puts the directive back immediately; nothing about the page's content changes either way. The only thing that is not instant is Google's recrawl.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — the metadata API emits it

Where: The `metadata` export (or `generateMetadata`) in the route's `page.tsx` or `layout.tsx`, and `next.config` headers.

  1. Search the route and every layout above it for `robots:` inside a `metadata` object. A `layout.tsx` near the root applies to every page under it, which is how one route's setting becomes the whole section's.
  2. Remove `index: false` / `follow: false`, or delete the `robots` key entirely to fall back to the default of indexable.
  3. Check `next.config.js`/`next.config.ts` for a `headers()` entry adding `X-Robots-Tag` — a header set there is invisible in the page source and overrides nothing you can see in the editor.
  4. Rebuild and redeploy: metadata is resolved at build time for statically rendered routes, so the live page keeps the old directive until it is rebuilt.

If it goes wrong: Re-adding the `robots` key restores the previous behaviour on the next deploy. There is no data migration and nothing else reads this value.

nginx — an X-Robots-Tag header is added by the server

Where: The `add_header X-Robots-Tag` line under /etc/nginx/ — usually in a server or location block.

  1. Find the rule rather than adding one that undoes it: `grep -rn 'X-Robots-Tag' /etc/nginx/`.
  2. Narrow or delete the directive in the block it is declared in. Deleting it is right when the whole site should be indexable; narrowing it to a `location` is right when only a staging path or a private directory was meant to be excluded.
  3. Remember that `add_header` in a `location` block discards the headers inherited from the enclosing `server` block unless you repeat them or use `always`. If the location sets other headers, re-declare them there.
  4. Test the configuration and reload rather than restarting: `sudo nginx -t && sudo systemctl reload nginx`.
  5. If the header survives the reload, something in front of nginx is adding it — a CDN, a reverse proxy, or the application itself. Check the response from the origin directly before changing anything else.

If it goes wrong: Keep the original file: `sudo cp /etc/nginx/sites-enabled/site.conf{,.bak}` before editing, and restore it with `sudo cp site.conf.bak site.conf && sudo nginx -t && sudo systemctl reload nginx`. `nginx -t` fails before a reload on a syntax error, so a bad edit does not take the site down — but it does mean the reload silently does nothing, so read its output.

Cloudflare — a Transform Rule or Worker adds the header

Where: Rules → Overview in the Cloudflare dashboard for this zone, and any Worker route in front of the origin.

  1. Open Rules → Overview and look through the response-header transform rules for one setting `X-Robots-Tag`. A rule added during a site launch to keep a staging domain out of search is the usual find.
  2. Either delete the rule or narrow its expression so it matches only the hostname or path you meant — `http.host eq "staging.example.com"` rather than the whole zone.
  3. Check Workers Routes as well: a Worker can add the header in code, and no rule in the dashboard will show it.
  4. Purge the cache for the affected URLs afterwards, or the previous response with the header is served from the edge until it expires.

If it goes wrong: Re-enable the rule — Cloudflare keeps disabled rules rather than deleting them, so disable rather than delete while you are confirming which rule was responsible.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what generates this page

Where: Whichever component emits the response: the page template, the application framework, the web server configuration, or the CDN. The two checks below tell you which.

  1. Find out which channel carries the directive, because they live in different places. `curl -sSI <url> | grep -i x-robots-tag` shows the header; `curl -sS <url> | grep -i 'name="robots"'` shows the meta tag.
  2. A meta tag is in the HTML, so it comes from the page template, the CMS, or an SEO plugin. Search your templates for `robots` and, if the site has a CMS, check its per-page SEO settings first — editing the template usually adds a second tag rather than changing the first.
  3. A header is not in the HTML, so it comes from the web server, the application, or the CDN. Ask the origin directly with `curl -sSI --resolve <host>:443:<origin-ip> https://<host>/` and compare: if the origin does not send it, whatever sits in front of it does.
  4. `none` is shorthand for `noindex, nofollow`, and `<meta name="googlebot">` counts as much as `<meta name="robots">`. Check for both spellings before concluding the page is clean.

If it goes wrong: Nothing here changes the page's content, so an unintended edit is reverted by putting the directive back. Make one change at a time — a meta tag and a header can both be present, and removing both at once tells you nothing about which was responsible.

Check it worked:

  • curl -sS <url> | grep -i 'name="robots"' — expect nothing, or a value with no `noindex`
  • curl -sSI <url> | grep -i x-robots-tag — expect nothing, or a value with no `noindex`
  • Use URL Inspection in Google Search Console, which reports what Googlebot itself saw rather than what we saw

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Where the page carries `noindex` *and* is blocked in robots.txt, the two cancel out: a crawler that is not allowed to fetch the page never reads the directive telling it not to index the page. Pick one. To keep the page out of results, remove the robots.txt block and keep `noindex`.

Google Search Central — Block search indexing with noindex · Google Search Central — Introduction to robots.txt

Fix robots.txt so crawlers read what you meant

A `Disallow` line that matches this page stops crawlers fetching it at all, so the page cannot be indexed on its own content and any `noindex` on it is never read. A robots.txt that answers with HTML rather than plain text is a different problem with the same effect: crawlers cannot parse it, and the directives you thought you had published are not in force.

Who makes this change: You — this is a change on your own site. robots.txt is a file at the root of your own site. Nobody else can publish it for you, and no search engine will ignore it on request.

Site root files — a real file in the document root

Where: `robots.txt` in the document root — the directory the web server serves `/` from.

  1. Fetch what is actually served rather than opening the file on disk: `curl -sS https://<host>/robots.txt`. A stale copy on disk and a generated response are easy to confuse.
  2. Find the `Disallow` line that matches the blocked path, and check which `User-agent` group it sits in — a rule under `User-agent: Googlebot` does not apply to other crawlers, and a crawler obeys only the most specific group that names it.
  3. Narrow the rule to the paths you meant to block rather than deleting the file. `Disallow: /` blocks everything and is almost always a launch setting left behind.
  4. Where one page inside a blocked directory should be crawlable, add an `Allow` line for it: the longer, more specific match wins, so `Allow: /private/public-page` beats `Disallow: /private/`.
  5. Serve the file as `text/plain`. An HTML error page served at that URL is not a robots.txt, and a 404 is a valid and well-defined answer meaning 'no restrictions'.

If it goes wrong: Keep a copy of the previous file before editing. Restoring it re-applies the old rules on the next fetch — crawlers re-read robots.txt frequently, typically within a day, so neither the change nor its reversal is permanent in any dangerous sense.

WordPress — WordPress serves a virtual robots.txt

Where: Settings → Reading in wp-admin, your SEO plugin's file editor, or a real `robots.txt` placed in the WordPress root.

  1. Check Settings → Reading first: 'Discourage search engines' makes WordPress serve a robots.txt that disallows everything.
  2. If an SEO plugin is installed, it owns the file. Yoast SEO and Rank Math both expose a robots.txt editor in their Tools section; edit it there rather than uploading a file, which the plugin will keep overriding.
  3. WordPress only serves a virtual robots.txt when no real file exists. If you upload one, it wins outright and every plugin setting stops having any effect — which is a reasonable choice, but make it deliberately.
  4. Confirm with `curl` rather than the plugin's preview, since a caching plugin or a CDN can be serving an older copy.

If it goes wrong: Deleting an uploaded `robots.txt` hands control back to WordPress and its plugins; restoring the plugin's previous text undoes an edit there. Neither touches site content.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — generated by the app

Where: `app/robots.ts` (or `public/robots.txt`) in the project.

  1. Look for `app/robots.ts` — it exports the rules as data, and a `disallow: '/'` there is the whole site.
  2. If there is no `app/robots.ts`, check `public/robots.txt`, which is served as a static file. Only one of the two can win; the route handler takes precedence.
  3. Narrow the `disallow` array to the paths you meant, and add the `sitemap` field while you are there — it is one line and it is the file's most useful content.
  4. Rebuild and redeploy. The file is generated at build time for a static export, so editing the source is not enough on its own.

If it goes wrong: Revert the commit and redeploy. Because the file is in version control, the previous state is exact rather than remembered.

We could not tell what serves this file

Where: Whatever answers `GET /robots.txt` — a static file, a framework route, or a catch-all that is answering by accident.

  1. Ask for the file and look at the response, not the rendered page: `curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' https://<host>/robots.txt`. Expect `200 text/plain`, or `404`.
  2. An HTML content type means a catch-all route is answering. Exclude `/robots.txt` from the rewrite so the static file, or the route that generates it, wins.
  3. A redirect means the file is being served from somewhere else. Crawlers do follow it, but the destination has to be a plain-text robots.txt on the same site to be useful — a redirect to the home page is the same as having no file.
  4. Once it is served correctly, check the rules with the robots.txt report in Google Search Console, which shows what Googlebot parsed rather than what you wrote.

If it goes wrong: If the site had no robots.txt before and you add one, removing it returns to the previous state exactly: a missing robots.txt means 'everything may be crawled', so there is no intermediate state to be stuck in.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Check it worked:

  • curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' https://<host>/robots.txt — expect 200 text/plain, or 404
  • curl -sS https://<host>/robots.txt | head -20 — confirm the rule is gone or narrowed, and that it is directives rather than markup
  • Use the robots.txt report in Google Search Console to confirm the URL is now allowed

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

A robots.txt that fails intermittently is worse than one that is missing, and the two behaviours differ by crawler. RFC 9309 says a crawler MUST assume complete disallow while the file returns a 5xx; Google instead stops crawling for about 12 hours and then falls back to the last good copy for up to 30 days. A 4xx is unambiguous in both: no restrictions. Size is also capped — Google parses the first 500 kibibytes and ignores the rest, which is the RFC's minimum parsing limit.

Google Search Central — Introduction to robots.txt · RFC 9309 — Robots Exclusion Protocol

Declare one canonical URL for the page

A canonical link tells search engines which URL of several identical ones should be the one in the index. Without it, or with two that disagree, the search engine picks for itself — and it may pick a tracking-parameter URL, an http:// variant, or a printer-friendly copy, splitting the signals that should have accumulated on one address. Google treats the canonical as a strong hint rather than a directive, so this is about giving a clear answer rather than about issuing an order.

Who makes this change: You — this is a change on your own site. The canonical link is markup in your own page, or a `Link` header from your own server. It is yours to set, and it is the one signal on this list that search engines are explicitly documented to weigh rather than obey.

WordPress — core or the SEO plugin emits it

Where: The SEO plugin's per-post 'Canonical URL' field, or the theme's `header.php` if a developer added one by hand.

  1. Do not add a canonical tag to the theme. WordPress core emits one already, and Yoast SEO and Rank Math each replace it; a hand-added tag becomes the second one and produces the conflicting-canonical finding.
  2. Where the value is wrong, set it in the SEO plugin's per-post Advanced panel, which has a Canonical URL field precisely for this.
  3. Where there are two tags, find the extra one: `curl -sS <url> | grep -ci 'rel="canonical"'` should return 1. Search the active theme for `rel="canonical"` and remove what you find there.
  4. Check the site address under Settings → General. A canonical pointing at http:// or at the wrong host usually traces back to that setting rather than to the page.

If it goes wrong: Clearing the plugin's canonical field returns the page to the generated self-referential value, which is the correct default. Nothing is lost by trying it.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — the metadata API emits it

Where: `metadata.alternates.canonical` in the route's `page.tsx`, or `metadataBase` in the root layout.

  1. Set `metadataBase` once in the root layout to the site's production origin. Without it, a relative canonical resolves against `localhost` during a build and ships that way.
  2. Set `alternates: { canonical: '/the/route' }` in the route's metadata and let `metadataBase` make it absolute, rather than hard-coding the host per page.
  3. Where two canonicals appear, check for a hand-written `<link rel="canonical">` in a layout — it is emitted in addition to, not instead of, the metadata API's.
  4. Rebuild and redeploy; statically rendered routes carry the value baked in at build time.

If it goes wrong: Revert the metadata change and redeploy. A canonical has no effect on how the page renders, so a wrong value is a search-visibility problem and never an outage.

Page HTML — hand-written templates

Where: The `<head>` of the page template, and any `Link:` response header your server adds.

  1. Emit exactly one `<link rel="canonical" href="https://example.com/page/">` in the `<head>`, with an absolute URL including the scheme and host.
  2. Generate the URL from the page's own route rather than hard-coding it, so it stays correct when the URL changes and cannot be copied wholesale into a new template.
  3. Percent-encode anything that needs it. A raw space or pipe in the value makes it unparseable, which is reported as an invalid canonical rather than as a missing one.
  4. Match the scheme, host and trailing-slash convention your redirects settle on. A canonical pointing at a URL that then redirects wastes the hint entirely.
  5. Check the response headers too: `Link: <...>; rel="canonical"` is equally valid, and a server that adds one while the page emits another is the usual source of a conflict.

If it goes wrong: Removing the tag returns the page to letting the search engine choose, which is the state it was already in. No content changes.

We could not tell what generates this page

Where: Whichever component writes the `<head>` — the CMS, the framework, or the template.

  1. Count what is there: `curl -sS <url> | grep -ci 'rel="canonical"'`. More than one is a conflict; zero means the search engine is choosing on its own.
  2. Check the headers separately: `curl -sSI <url> | grep -i '^link:'`. A canonical can arrive in either channel and the page source shows only one of them.
  3. Decide which component should own the value — normally the application or CMS, not a plugin and not the CDN — and disable the others rather than configuring them to agree, so they cannot drift apart again.
  4. Confirm the surviving URL returns 200 rather than redirecting, and that it is the address you actually want in search results.
  5. Do not canonicalise paginated pages to page one: each page of a series is its own canonical, and pointing them all at the first hides the rest.

If it goes wrong: Restore the removed declaration if the wrong component turns out to own it. Every state here is reversible in one edit, and none of it affects what a visitor sees.

Check it worked:

  • curl -sS <url> | grep -i 'rel="canonical"' — expect exactly one, an absolute https:// URL
  • curl -sSI <url> | grep -i '^link:' — expect no second canonical in the headers
  • curl -sSI <the canonical URL> | head -1 — expect 200, not a redirect

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Google documents none of this as required: a site with no canonical declaration at all 'will likely do just fine', because the search engine picks one from redirects, internal links and the sitemap. What a canonical does is make that choice yours rather than an inference, and it is documented as a strong signal rather than a directive — Google can and sometimes does choose a different URL.

Google Search Central — How to specify a canonical URL

Give the page a title that survives truncation

The title is the clickable line in a search result and the label on a browser tab. With none, search engines generate one from the page's content, and what they generate is rarely what you would have written. With a very long one, the end is cut off — so a title whose distinctive words are at the end reads, in the result, as the site name and nothing else.

Who makes this change: You — this is a change on your own site. This is text in your own page. Google may rewrite a title it considers unhelpful, and documents that it does — which is a reason to write a good one, not a reason it is out of your hands.

WordPress — the theme or the SEO plugin builds it

Where: The per-post SEO panel's title template, and the plugin's site-wide title templates.

  1. Set the page's title in the SEO plugin's snippet editor rather than in the theme: modern themes call `wp_head()` and the plugin composes the title from a template.
  2. Fix the template, not only this page, if the pattern is site-wide — a template of `%%sitename%% — %%title%%` puts the site name first on every page, which is the shape that gets truncated worst.
  3. Where two `<title>` elements appear, a theme is emitting one directly. Search the theme for `<title>` and remove it, leaving `wp_head()` to do the job.
  4. Clear any page cache afterwards: a cached HTML copy keeps serving the old title regardless of what the database says.

If it goes wrong: Titles are content: restoring the previous text restores the previous result. Keep the old title in the revision history or in a note before rewriting a page that ranks well, so a rewrite that loses clicks can be undone exactly.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — the metadata API builds it

Where: `metadata.title` in the route, and `title.template` in the root layout.

  1. Set `title` per route and a `title: { template: '%s — Site name', default: 'Site name' }` in the root layout, so every page gets the suffix without repeating it.
  2. Put the page-specific words in the per-route value and the site name in the template's suffix — that is what keeps the distinctive part in front of the truncation point.
  3. Remove any `<title>` written directly in a component: it is emitted alongside the metadata API's, and duplicate titles are what the duplicate-tag finding reports.
  4. Rebuild and redeploy for statically rendered routes.

If it goes wrong: Revert the change and redeploy; nothing else depends on the value.

Page HTML — hand-written templates

Where: The `<title>` element in the page's `<head>`.

  1. Write one `<title>` per page, describing what this page is about rather than what the site is.
  2. Put the distinctive words first and the site name last. Treat about sixty characters as the point where truncation becomes likely — it is a display convention based on the width of the link, not a published limit, and meaning beats length.
  3. Make it unique across the site. A title repeated on every page tells a searcher nothing about which result to open.
  4. Render it server-side. A title set by JavaScript is not in the served HTML, and not every consumer of the page runs scripts.
  5. Drop filler: 'Welcome to', 'Home page of', and a brand name repeated twice all spend characters that the truncation will take back.

If it goes wrong: Keep the previous title. Restoring it is a one-line edit, and it is worth doing if a rewritten title loses click-through in Search Console's performance report over the following weeks.

We could not tell what generates this page

Where: Whichever component writes the `<head>`.

  1. Look at what is actually served: `curl -sS <url> | grep -i '<title'`. If JavaScript sets the title, this returns nothing or a placeholder, which is itself the finding.
  2. Count them: more than one `<title>` means two components are both writing the head. Decide which one owns it — normally the application's own head management, not a plugin — and remove the tag from the other rather than syncing the values.
  3. Rewrite the text so the page-specific words come first, then confirm what is served rather than what the editor previews.
  4. Watch the result in Search Console's performance report rather than in a pixel-width calculator: it shows what was actually displayed, including any title Google chose to rewrite.

If it goes wrong: Every step here is a text edit with an exact previous value. Nothing about the page's behaviour changes, so reverting is always safe.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Check it worked:

  • curl -sS <url> | grep -i '<title' — expect exactly one, descriptive and non-empty
  • curl -sS <url> | grep -ci '<title' — expect 1

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Google publishes no character limit for titles, and states that it may replace a title link with text drawn from the page's own headings. The sixty-character guidance here is about the width of the result link before truncation, and is ours rather than Google's.

Google Search Central — Influencing your title links in search results

Write a meta description that earns the click

The meta description is the paragraph under the title in a search result. It is not a ranking input and no amount of keyword-stuffing it will move a position — what it decides is whether the person reading the result chooses yours. With none, or with one that repeats the title word for word, the search engine writes its own from the page text, which is often a fragment of navigation or a cookie notice.

Who makes this change: You — this is a change on your own site. Text in your own page. Search engines will use it or replace it at their discretion, and Google documents that a snippet is often generated from the page for a specific query.

WordPress — the SEO plugin's snippet editor

Where: The 'Meta description' field in the SEO plugin panel below the post editor.

  1. Write the description in the plugin's snippet editor for this page, rather than relying on the plugin's auto-generated excerpt.
  2. Check the plugin's site-wide template for the post type: a template that reuses the title is what produces the identical title and description reported here.
  3. Where two description tags appear, a theme or a second plugin is emitting one; remove it there rather than changing the plugin's value.
  4. Clear the page cache so the new markup is served.

If it goes wrong: Descriptions are content and the previous text is in the post's revision history. There is nothing here that can break the page.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — the metadata API

Where: `metadata.description` in the route's `page.tsx` or `generateMetadata`.

  1. Set `description` per route. A single description inherited from the root layout is site-wide duplication, which is worse than having none.
  2. Where the description is generated from content, trim on a sentence boundary rather than at a character count, so the snippet is never cut mid-word.
  3. Make sure it says something the title does not: repeating the title is reported separately and wastes the only line you control.
  4. Rebuild and redeploy.

If it goes wrong: Revert and redeploy; the value affects nothing but the snippet.

Page HTML — hand-written templates

Where: `<meta name="description" content="…">` in the page `<head>`.

  1. Add one description per page, in one or two sentences, in the words a searcher would use.
  2. Lead with the page's offer or answer as a complete sentence, so it stands on its own if the rest is cut.
  3. Aim for roughly 50 to 160 characters. Both ends are guidance — Google publishes no limit and truncates by pixel width, not by character count.
  4. Do not stuff keywords. The description is not a ranking input, and a list of terms reads badly to the person deciding whether to click.
  5. Keep it distinct from the title and distinct from every other page's.

If it goes wrong: A text edit with an exact previous value; reverting is one line.

We could not tell what generates this page

Where: Whichever component writes the `<head>`.

  1. Check what is served: `curl -sS <url> | grep -i 'name="description"'`.
  2. If two appear, find where the second comes from — a template default and a plugin both writing the head is the usual cause — and remove one rather than making them agree.
  3. If the description matches the title exactly, look for a template that falls back to the title when no description is set; fixing the fallback fixes every page at once.
  4. Check a second page before concluding it is per-page: a site-wide duplicate description needs a different fix from one page's missing one.

If it goes wrong: Text only, reverted by restoring the previous value.

Check it worked:

  • curl -sS <url> | grep -i 'name="description"' — expect exactly one, and text that differs from the title
  • curl -sS <url> | grep -ci 'name="description"' — expect 1

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Google states plainly that it may generate a snippet from the page content instead of using your description, and that the choice varies by query. Writing a good one raises the odds; it does not guarantee the text shown.

Google Search Central — Control your snippets in search results

Publish a sitemap and point robots.txt at it

A sitemap is how a crawler finds URLs that nothing links to prominently — new pages, deep pages, pages reached only through a search form. It does not make pages rank and it does not guarantee they are indexed; what it changes is discovery, and on a large or a new site that is the difference between being crawled this week and next month. A sitemap that returns HTML, or that nothing points at, does none of that while looking like it does.

Who makes this change: You — this is a change on your own site. The sitemap is a file on your own site and the `Sitemap:` line is in your own robots.txt. Search Console submission is an extra route, not a substitute.

WordPress — core or the SEO plugin generates it

Where: `/wp-sitemap.xml` from WordPress core, or the SEO plugin's sitemap (Yoast publishes `/sitemap_index.xml`).

  1. Find which sitemap is live: fetch `/wp-sitemap.xml` and `/sitemap_index.xml` and see which answers with XML. WordPress has generated the first since version 5.5; an SEO plugin usually disables it and publishes its own.
  2. Do not leave both active. Two sitemaps listing the same URLs is not harmful, but it makes the Search Console coverage report much harder to read.
  3. Add the live sitemap's absolute URL to robots.txt as a `Sitemap:` line, through the plugin's robots.txt editor if one is installed.
  4. If the sitemap returns a 404, check permalinks: flushing them under Settings → Permalinks regenerates the rewrite rules that serve it.

If it goes wrong: Nothing here removes content. Disabling a sitemap returns the site to being discovered by links alone, which is where it was.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — generated by the app

Where: `app/sitemap.ts`, or a `public/sitemap.xml` file.

  1. Export a `sitemap()` function from `app/sitemap.ts` returning your routes; Next.js serves it at `/sitemap.xml` and sets the XML content type for you.
  2. Generate the list from the same source the routes come from — a hand-maintained array goes stale the first time someone adds a page.
  3. Add the sitemap URL to `app/robots.ts` under `sitemap`, so the declaration and the file are generated together and cannot disagree.
  4. For more than 50,000 URLs, return a sitemap index and split the files — that is the protocol's limit, alongside 50 MB uncompressed per file.

If it goes wrong: Revert the file and redeploy. Crawlers re-read robots.txt within days.

Site root files — a static file you maintain

Where: `sitemap.xml` in the document root, and the `Sitemap:` line in `robots.txt`.

  1. Generate the file from your build rather than by hand. A stale sitemap full of URLs that now 404 is worse than no sitemap, because every entry is a crawl request you asked for.
  2. List canonical, indexable URLs only: no redirects, no 404s, nothing carrying `noindex`, and the canonical variant of each address rather than a tracking-parameter version.
  3. Add `Sitemap: https://example.com/sitemap.xml` to robots.txt on its own line. It is a global directive and does not belong to any `User-agent` group, so its position in the file does not matter.
  4. Use the absolute URL — a relative value is not valid for this directive and is ignored.
  5. Serve it with an XML content type and without a redirect.

If it goes wrong: Remove the `Sitemap:` line and the file to return to the previous state. Nothing a visitor sees is affected.

We could not tell what generates this site

Where: Whatever answers `/sitemap.xml`, plus `robots.txt` at the site root.

  1. Look at the raw response rather than the browser's rendering: `curl -sS https://<host>/sitemap.xml | head -3`. It should begin with `<?xml` and a `<urlset` or `<sitemapindex` root element.
  2. HTML coming back means a catch-all route is answering. Exclude the sitemap path from the rewrite so the generated file wins.
  3. If the generator was removed, either restore it or remove the `Sitemap:` line from robots.txt, so nothing points at a dead URL.
  4. Submit the sitemap in Google Search Console as well. The two routes are independent, both are free, and Search Console is where you find out which URLs were rejected and why.
  5. Sitemaps may also be plain text or RSS, which is worth knowing if generating XML is awkward in your stack — one URL per line in a text file is a valid sitemap.

If it goes wrong: Removing the declaration returns to discovery by links. Nothing about the site's content or availability depends on the sitemap.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Check it worked:

  • curl -sS https://<host>/sitemap.xml | head -3 — expect <?xml and a <urlset or <sitemapindex root
  • curl -sS https://<host>/robots.txt | grep -i '^sitemap:' — expect the sitemap's absolute URL
  • In Google Search Console, open Sitemaps and confirm the last read succeeded with the URL count you expect

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Google Search Central — Build and submit a sitemap · sitemaps.org — XML sitemap protocol 0.9

Put the page's headings in order

Headings are how the page states its own structure. Someone using a screen reader navigates by them — jumping heading to heading is the equivalent of skimming — so a page with no `h1` opens with no statement of what it is, and a jump from `h2` to `h4` reads as though a section was missed. The search effect is secondary and real: Google documents using headings, among other signals, when it replaces a title link.

Who makes this change: You — this is a change on your own site. Markup in your own templates and components. Nothing outside your site influences it.

Page HTML — page templates and components

Where: The template or component that renders the page's headline and section headings.

  1. Find the visible headline. If it is a styled `<div>` or `<p>`, change the element to `<h1>` and keep the class — the element carries the structure, the class carries the appearance.
  2. Use one `<h1>` for the page's own subject. A site name in a shared header is not the page's subject, and putting the `<h1>` there gives every page the same heading.
  3. Fix a skipped level by choosing the next level down and styling it, rather than choosing the element by the size you wanted.
  4. Look at the component rather than the page: a card or a widget that hard-codes `<h4>` produces this finding wherever it is reused, and fixing one page leaves the rest.
  5. Render headings server-side so they are in the served HTML.

If it goes wrong: Changing an element changes which CSS rules match it. Move the existing class onto the new element in the same edit and the appearance is unchanged; if something does shift, the previous element name restores it exactly.

WordPress — the theme and the block editor

Where: The block editor's heading level selector for this page, and the theme's template for the post type.

  1. In the block editor, select the heading block and set its level in the toolbar. The block editor exposes the level directly, so no code change is needed for content headings.
  2. If the page has no `h1` at all, check whether the theme renders the post title as one. Some themes render it as an `h2` inside an archive-style template that was reused for single pages.
  3. Where a page builder generates the markup, set the heading tag in the element's settings rather than choosing a text size — the two are separate controls and only one of them is structure.
  4. Check a second page of the same type: a theme-level cause repeats, a content-level cause does not.

If it goes wrong: Heading levels are stored with the content and are in the revision history, so any change here is revertible from the editor.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what generates this page

Where: The template, component or content block that emits the heading.

  1. List what is served: `curl -sS <url> | grep -oiE '<h[1-6][^>]*>' | head -20`. This shows the order the levels appear in, which is what the check reads.
  2. Add or correct the top-level heading first. Everything else follows from having one.
  3. Walk the list top to bottom and make sure no level is more than one below the previous one. Going back up several levels is fine; skipping down is what confuses a reader navigating by heading.
  4. Fix it where the markup is generated. A find-and-replace on one page leaves every other page using the same component unchanged.

If it goes wrong: The previous markup is the rollback. Keep the class attributes when changing an element and the visual result does not move.

Check it worked:

  • curl -sS <url> | grep -ci '<h1' — expect at least 1
  • curl -sS <url> | grep -oiE '<h[1-6]' — read the order and confirm no level is skipped going down

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

The HTML specification does not forbid more than one `h1`, and modern screen readers cope with several. The guidance here — one `h1` for the page's subject, no skipped levels going down — is a readability convention supported by WCAG 1.3.1, not a validity requirement.

WHATWG HTML — Headings and outlines · W3C WCAG 2.2 — Success Criterion 1.3.1 Info and Relationships · Google Search Central — Influencing your title links in search results

Give every image alt text, including the decorative ones

An image with no `alt` attribute gives someone using a screen reader nothing in its place — often the file name read aloud — and gives image search no text to work from. The fix is not to write something for every image: the correct answer for a purely decorative image is an empty `alt=""`, which tells assistive technology to skip it. What the specification does not allow is leaving the attribute out, because then nothing has been said either way.

Who makes this change: You — this is a change on your own site. Yours. The text belongs with the image, so the durable fix is in whatever uploads or defines images rather than in this page's markup.

Page HTML — templates and components

Where: The template or component that renders each `<img>`, and any component that hard-codes one.

  1. For an image that carries meaning, write what it conveys in this context. The test in the specification is that replacing the image with its alt text would not change the meaning of the page — so it is not a description of every visual detail, and it is not a caption.
  2. For a purely decorative image, write `alt=""`. The W3C's own decision tree gives this as the correct answer, and it is deliberate rather than a shortcut: it is how you say "there is nothing here to announce".
  3. Do not omit the attribute instead. An absent `alt` is what we report, and it is what assistive technology reads as an unlabelled image rather than as a decoration.
  4. For an image inside a link, describe where the link goes rather than what the picture shows: the alt text is the link's name.
  5. Do not repeat text that is already beside the image. Two announcements of the same thing is worse than one.
  6. Purely decorative chrome is better handled in CSS than in markup — the specification says so — which removes the question rather than answering it.

If it goes wrong: Alt text is additive and affects nothing a sighted visitor sees. There is nothing to undo, and no state to be stuck in.

WordPress — the media library

Where: The 'Alternative Text' field in the media library, and the theme or builder template for images it generates itself.

  1. Set alt text where images are uploaded, in the media library, so it travels with the image everywhere it is used rather than being set per page.
  2. Leave the field empty deliberately for decorative images — WordPress emits `alt=""` in that case, which is the correct markup.
  3. Check the theme and any page builder for images it generates itself: a decorative background or an icon emitted by a template will not appear in the media library at all.
  4. Do not paste the file name or the title into the field in bulk. An automated fill makes the check pass and makes the page worse for the person the attribute exists for.

If it goes wrong: A text field, revertible from the same panel, with no effect on layout.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell how this page is built

Where: Whatever emits the `<img>` elements — a CMS, a framework, or a hand-written template.

  1. List the images with no attribute at all: `curl -sS <url> | grep -o '<img[^>]*>' | grep -v 'alt=' | head -20`.
  2. Decide for each one whether it carries meaning or is decoration. That decision is the work; the markup is trivial once it is made.
  3. Write real text for the meaningful ones and `alt=""` for the rest.
  4. Fix it where images are uploaded or components are defined, so images added next month arrive with alt text rather than needing another audit.

If it goes wrong: Nothing here changes what the page looks like, so there is nothing to revert.

Check it worked:

  • curl -sS <url> | grep -o '<img[^>]*>' | grep -cv 'alt=' — expect 0
  • Read the page with a screen reader, or with images disabled in the browser, and check the result still makes sense

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

We count an image as missing alt text only when the attribute is absent. `alt=""` is counted as a deliberate decoration and is never reported, so this check cannot tell you that a meaningful image was wrongly marked decorative — only a person looking at the page can.

WHATWG HTML — Requirements for providing text to act as an alternative for images · W3C WAI — An alt decision tree · MDN — <img> alt attribute

Make the structured data parse and say what the page says

JSON-LD that does not parse is discarded entirely — the markup is on the page and is doing nothing, which is worse than absent because it looks done. A block that parses but is missing a property the type requires is ignored for rich results for the same reason: Google's documentation lists required properties per type, and a type missing one is not eligible. Structured data does not improve ranking on its own; what it can do is change how the result is displayed.

Who makes this change: You — this is a change on your own site. The markup is generated by your own page or plugin. Whether a rich result is shown is Google's decision, and it is documented as never guaranteed even when the markup is perfect.

Page HTML — markup generated in a template

Where: `<script type="application/ld+json">` in the page, and the code that emits it.

  1. Copy the block into Google's Rich Results Test, which names the exact position of a parse error and lists missing required properties per type.
  2. For a parse failure, look for a trailing comma, a comment, single quotes, or an unescaped quote inside a string. Those four account for nearly all of them.
  3. Build the object in code and serialise it with a real JSON encoder rather than interpolating values into a JSON-shaped string in a template. A visitor-supplied apostrophe in a product name is enough to break the hand-built version.
  4. Make sure the serialiser escapes `<`, so a value containing `</script>` cannot close the element early.
  5. For a missing required property, add it with a value that matches what is visible on the page. Where the data genuinely does not exist — ratings, most often — remove the type rather than invent the value; Google's guidelines treat invented structured data as spam.

If it goes wrong: Removing a JSON-LD block returns the page to plain results. Nothing a visitor sees changes, and any rich result already earned fades over the following crawls rather than disappearing at once.

WordPress — a plugin generates the markup

Where: The SEO plugin's schema settings for this post type, and any second plugin also emitting JSON-LD.

  1. Find out which plugin emits the block — Yoast SEO, Rank Math and most e-commerce plugins each generate their own graph, and two of them together is the usual source of duplicated or contradictory types.
  2. Set the missing property in the plugin's schema panel where it exposes one, rather than adding a second block in the theme.
  3. Where the value should come from the content, check that the field it reads is actually filled in: an empty product price field produces markup missing a required property rather than an error anywhere visible.
  4. Disable the duplicate emitter rather than editing both.

If it goes wrong: Plugin settings are reversible from the same panel, and disabling a schema feature never removes content.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what generates this page

Where: Whatever emits the `<script type="application/ld+json">` block.

  1. Extract what is served: `curl -sS <url> | sed -n '/application\/ld+json/,/<\/script>/p'`, and paste the result into a JSON validator to see the parse error's position.
  2. Run the URL through the Rich Results Test for the complete check across every type on the page, including the required properties the JSON validator cannot know about.
  3. Fix it in the component that generates the markup, not in the rendered output, or the next page load regenerates the fault.
  4. If you cannot find the generator, search the page source for the value the block contains — a product name or an author — which usually identifies which plugin or template owns it.

If it goes wrong: Structured data is additive. Removing it puts the page back exactly where it was before the markup existed.

Check it worked:

  • curl -sS <url> | sed -n '/application\/ld+json/,/<\/script>/p' — paste into a JSON validator and confirm it parses
  • Run the URL through Google's Rich Results Test and confirm no errors for the types you intend

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Some types no longer produce a rich result at all: FAQPage was retired in May 2026 and HowTo in September 2023. Both remain valid schema.org vocabulary, so markup using them is correct and simply no longer changes how the result looks. That is a dated snapshot — Google's gallery is the current list.

Google Search Central — Structured data general guidelines · Google — Rich Results Test

Complete the sharing preview markup

Open Graph tags decide what a link to this page looks like when someone pastes it into a chat, a social network or a messaging app. Without them the consumer guesses — usually the first image on the page, which may be a logo or a tracking pixel, and a title taken from wherever it can find one. This has no search-ranking effect at all; what it changes is whether a shared link looks like something worth opening.

Who makes this change: You — this is a change on your own site. Markup in your own page head. Each platform decides how to render the preview and caches it on its own schedule, which is why a fixed page can keep showing the old card for a while.

Page HTML — hand-written templates

Where: The `<head>` of the page template.

  1. Add the four properties the Open Graph protocol lists as required: `og:title`, `og:type`, `og:image` and `og:url`.
  2. Use an absolute URL for `og:image` — a relative path is not resolved by most consumers — and serve the image over https.
  3. Use an image around 1200×630 pixels for a large card. That figure is Facebook's guidance rather than the Open Graph specification, which sets no dimensions at all: Facebook documents a 200×200 minimum, an aspect ratio close to 1.91:1, and an 8 MB file-size ceiling. Other consumers apply their own rules.
  4. Set `og:url` to the page's canonical URL, so shares of tracking-parameter variants consolidate onto one address.
  5. Add `og:description` as well. It is not required by the protocol but it is what fills the card's body text, and without it the consumer picks something from the page.

If it goes wrong: These tags affect nothing but link previews. Removing them returns to the consumer guessing, which is the state the page is in now.

Next.js — the metadata API

Where: `metadata.openGraph` in the route, and `metadataBase` in the root layout.

  1. Set `openGraph: { title, type, url, images }` per route. `metadataBase` makes the image and URL absolute, which is the requirement most often missed.
  2. Put the image in the route's own metadata rather than only in the root layout, or every page shares one preview image.
  3. Next.js generates the `og:*` tags for you; do not also hand-write them in a component, or the page carries two of each.
  4. Rebuild and redeploy, then re-share the link to see the new card.

If it goes wrong: Revert and redeploy; nothing else reads these values.

WordPress — the SEO plugin emits them

Where: The 'Social' tab of the SEO plugin's per-post panel, and its site-wide social defaults.

  1. Set the social image and title in the plugin's Social tab for this page; both Yoast SEO and Rank Math emit the Open Graph tags from there.
  2. Set a site-wide default social image in the plugin's settings, so a page with none still gets a deliberate one rather than whatever image the consumer finds.
  3. Turn off the feature in one plugin if two are installed — duplicate `og:` tags are ignored inconsistently by different consumers, which produces previews that differ per platform for no visible reason.
  4. Clear the page cache and any CDN cache before re-sharing.

If it goes wrong: Plugin settings revert from the same panel, and no content is affected.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

We could not tell what generates this page

Where: Whichever component writes the `<head>`.

  1. See what is there: `curl -sS <url> | grep -i 'property="og:'`.
  2. Add whichever of `og:title`, `og:type`, `og:image` and `og:url` is missing, in the component that writes the rest of the head.
  3. Check the image resolves on its own: paste the `og:image` value into a browser. A 404 or an image behind authentication produces a card with no picture and no error message anywhere.
  4. Expect a delay. Most platforms cache the preview per URL, so after fixing the tags you may need to re-request it through that platform's own sharing debugger before the new card appears.

If it goes wrong: Preview markup is additive and reversible; nothing on the page depends on it.

Check it worked:

  • curl -sS <url> | grep -i 'property="og:' — expect og:title, og:type, og:image and og:url
  • curl -sSI <the og:image URL> | head -1 — expect 200, and an image content type

Test again re-runs the page audit immediately and will confirm the tags are now served. What it cannot do is refresh the preview a social platform has already cached for this URL — each platform has its own sharing debugger for that, and some caches expire only after several days.

The Open Graph protocol

Correct the language and region annotations

hreflang tells a search engine which language or regional version of a page to show which audience. Google requires each version to list itself along with all the others, and it requires the links to be reciprocal — if two pages do not both point at each other, that pair is ignored. The failure is per pair rather than across the whole group, so a set with one bad link is not destroyed, it is quietly incomplete: the versions that still point at each other keep working while the rest stop being offered, and some visitors keep landing on the wrong-language page.

Who makes this change: You — this is a change on your own site. The annotations are markup, headers or sitemap entries on your own site. Where the language versions live on different domains you control, all of them have to agree — one uncooperative domain breaks the set for the rest.

Page HTML — link elements in the page head

Where: `<link rel="alternate" hreflang="…">` elements in the `<head>` of every language version.

  1. Emit the complete list of versions on every page, including the page you are on. Google states that each version must list itself as well as all the others; a set where each page names only the others is the missing-self-reference finding.
  2. Use a hyphen and the right order: `en-GB`, never `en_GB` and never `gb-en`. Google supports ISO 639-1 language codes with an optional ISO 3166-1 alpha-2 region, and a region on its own is invalid — `be` is Belarusian, not Belgium. `UK` is not a region code at all: Google documents that it has no effect, so `en-UK` degrades to plain `en`. The United Kingdom is `GB`.
  3. Use absolute URLs including the scheme.
  4. Add an `x-default` entry naming the version to show a reader whose language matches none of the others — typically a language selector page, which is what Google says it was designed for. It is recommended rather than required, and we do not report its absence as a fault.
  5. Generate the whole set from one source shared by every version, so adding a language cannot leave the existing pages listing an incomplete set.

If it goes wrong: Removing the annotations returns each page to being ranked on its own, which is the effective state while the set is invalid. No visitor-facing behaviour changes either way.

Next.js — the metadata API

Where: `metadata.alternates.languages` in the route or in a shared metadata helper.

  1. Set `alternates: { languages: { 'en-GB': '/en-gb/page', 'de-DE': '/de/seite' } }`, generated from the same routing table the localised routes come from.
  2. Include the current locale in the map. The metadata API emits exactly what you give it and will not add the self-reference for you.
  3. Set `metadataBase` so the values are emitted as absolute URLs.
  4. Add `'x-default'` as a key in the same map for the fallback version.

If it goes wrong: Revert and redeploy; hreflang affects search targeting only.

We could not tell what generates this page

Where: The page head, the `Link` response headers, or the sitemap — hreflang is valid in all three.

  1. See what is served: `curl -sS <url> | grep -i hreflang`, and `curl -sSI <url> | grep -i '^link:'` for the header form.
  2. Correct the malformed tags first — an invalid language tag is ignored, so the version it named is simply absent from the set.
  3. Fetch one of the other versions and confirm it names this page back. Google ignores any pair that does not point both ways — the reason given is that otherwise anyone could nominate their site as an alternate version of yours.
  4. If the set is large, move it to the sitemap instead of the page head: Google treats the head, the HTTP header and the sitemap as equivalent, and one generated file is easier to keep consistent than markup on every page. Use one of the three rather than several — there is no benefit in doing it twice, and two sources drift.
  5. Check the international targeting report in Google Search Console, which lists return-link errors across the whole set rather than one page at a time.

If it goes wrong: Every form of this annotation is additive metadata. Removing it is always safe and never affects what a visitor is served.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Check it worked:

  • curl -sS <url> | grep -i hreflang — the page's own URL should appear in its own set
  • curl -sS <one of the other language URLs> | grep -i hreflang — it should name this page back

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

We check the annotations on the page we fetched, and only their shape: we confirm the tag looks like a language tag and that the URL is absolute. We do not check the codes against the ISO registries, so `uk` used for the United Kingdom passes this check while meaning Ukrainian. We also do not fetch the other language versions, so we cannot confirm the return links — that is what Search Console's international targeting report is for.

Google Search Central — Localized versions of your page · BCP 47 / RFC 5646 — Tags for Identifying Languages

Declare the language the page is written in

The `lang` attribute on the `<html>` element is what tells software which language the page is in. A screen reader uses it to choose a voice and a pronunciation dictionary: English read out by a synthesiser still set to French is not accented, it is close to unintelligible. The browser uses it for hyphenation, for quote marks and for whether to offer a translation. WCAG 2.2 Success Criterion 3.1.1 (Language of Page) is Level A — the lowest bar there is — and an attribute that does not parse fails it exactly as an absent one does, silently, while the markup looks correct.

Who makes this change: You — this is a change on your own site. One attribute on one element, emitted by your own layout or template. No DNS, no host and no third party is involved, and nothing outside your site can set it for you.

Page HTML — the page template or layout

Where: The `<html>` element in the base layout, master page or partial that wraps every page.

  1. Set it on the root element, not anywhere else: `<html lang="en">`. There is no meta-tag equivalent, and `lang` on `<body>` describes the body element rather than the document — the page-level declaration the standard defines lives on `<html>`.
  2. Set it once, in the layout every page inherits, rather than per page. A declaration that is right on the home page and absent on the rest is the usual shape of this finding.
  3. Choose the primary language subtag from BCP 47: `en`, `de`, `pt`, `ja`. Two or three letters, lowercase by convention.
  4. Add a region subtag only when it changes the content a reader gets — `pt-BR` and `pt-PT` differ in vocabulary and in how a synthesiser reads them; `en` on its own is the better declaration when you are not sure. Use a hyphen, never an underscore, and put the language first: `en-GB`, never `en_GB` or `gb-en`.
  5. Mark passages that are in another language on the element that contains them: `<span lang="fr">en français</span>`. That is WCAG 3.1.2 (Language of Parts, Level AA) and it is what keeps the page-level declaration honest instead of being changed to suit one paragraph.

If it goes wrong: Adding or correcting the attribute changes nothing a sighted reader sees, and the CSS `:lang()` selector is the only styling hook that can respond to it. Restoring the previous value undoes it exactly.

WordPress — the theme and the site language setting

Where: `language_attributes()` in the theme's `header.php`, and Settings → General → Site Language.

  1. Confirm the theme opens with `<html <?php language_attributes(); ?>>` rather than a hard-coded `<html>`. A theme that hard-codes the tag is the commonest cause of this finding on WordPress, and it also ignores the site language when it is changed.
  2. Set Settings → General → Site Language to the language the content is actually written in. `language_attributes()` derives the attribute from it, so the setting and the markup cannot drift.
  3. On a multilingual site, let the translation plugin set it per language rather than hard-coding one value — the plugin knows which translation is being rendered and the template does not.
  4. Check a second page and a post, not only the home page: the header template is shared, so a correct home page means the whole site is correct, and an incorrect one means none of it is.

If it goes wrong: Both the setting and the template edit are revertible in place; the site language is a single option and the theme file is in version control or restorable from the theme's original copy.

Checked 2026-09. Control panels are redesigned without notice, so treat the click path as a snapshot rather than as fact.

Next.js — the root layout

Where: The `<html>` element returned by `app/layout.tsx` (or `pages/_document.tsx`).

  1. Set `lang` on the `<html>` element the root layout returns: `<html lang="en">`. Next.js renders that element itself, so the attribute has to be on it — adding one further down the tree produces invalid markup rather than a second declaration.
  2. On a localised app, take the value from the same route segment or locale the page is rendered for, so the attribute cannot disagree with the content.
  3. If you use `pages/`, the element is in `_document.tsx` instead, and the same rule applies: the value comes from the locale, not from a constant.
  4. Re-render and read the served bytes rather than the component source — a value computed at request time is the one that has to be right.

If it goes wrong: Revert the layout and redeploy; nothing else depends on the attribute.

We could not tell what generates this page

Where: Whatever emits the opening `<html>` tag — a template, a layout, or a static file.

  1. See what is served: `curl -sS <url> | grep -io '<html[^>]*'`. That prints the opening tag with its attributes, which is exactly what this check reads.
  2. If there is no `lang` at all, add one with the language of the page's own content. Where a site serves several languages from one template, the value has to be computed per page rather than fixed.
  3. If there is one and it was reported as malformed, the usual causes are an underscore instead of a hyphen, the subtags in the wrong order, or a country code used as a language — `uk` is Ukrainian, and the United Kingdom is `GB` inside `en-GB`.
  4. Check the subtags against the IANA Language Subtag Registry when the language is not one you work in. A tag can be shaped correctly and still name a language you did not mean, which is a mistake no syntax check can catch for you.
  5. Fix it where the tag is generated, not on one page. This attribute is almost always emitted by a single shared template, so one edit covers the site — and a per-page patch leaves every other page as it was.

If it goes wrong: The previous markup is the rollback. No visitor-facing layout or behaviour depends on this attribute beyond the language handling it enables.

Check it worked:

  • curl -sS <url> | grep -io '<html[^>]*' — the tag should carry a lang attribute
  • Open the page in a browser with a screen reader running and listen to the first sentence — the wrong voice is immediately audible, and it is the fastest confirmation there is.

Test again re-runs the page audit against the live page straight away, so our report reflects the change within seconds. Google is slower: the page has to be recrawled before search results follow, which takes days rather than minutes and is not something any checker can hurry. Requesting indexing in Search Console's URL Inspection tool is the only lever you have on that.

Two limits worth stating. We check the **shape** of the tag against BCP 47, not the subtags against the IANA registry, so a well-formed tag that names the wrong language passes this check. And this is an accessibility and user-agent requirement rather than a search one: Google's documentation on localized versions describes language targeting through the page's visible content and `hreflang` annotations and does not name `lang` among the signals it uses, so nothing here claims a ranking effect. `lang` and `hreflang` are different things and can be wrong independently — `lang` declares what *this* page is written in, `hreflang` connects it to its translations, and a page can have a perfect hreflang set while declaring the wrong language to every screen reader that opens it.

W3C WCAG 2.2 — Success Criterion 3.1.1 Language of Page · WHATWG HTML — The lang and xml:lang attributes · BCP 47 / RFC 5646 — Tags for Identifying Languages · Google Search Central — Localized versions of your page

About the seo checker

This is a technical, on-page SEO check of a single page. It reads the HTML your server actually sends, plus /robots.txt and up to two sitemap URLs, and reports the things that decide whether a search engine can crawl the page, whether it is allowed to index it, which URL it will treat as canonical, and what it has to work with when it builds a result. It is not a keyword tool, a rank tracker or a backlink index, and it does not crawl your site.

Indexability comes first because everything else is moot without it. The check reads robots and googlebot meta tags and the X-Robots-Tag header for noindex, nofollow and none, and evaluates your robots.txt against the page's own path using the longest-match rules of RFC 9309. It also catches the combination that quietly costs sites their whole index presence: a noindex directive on a page that robots.txt blocks, where the crawler never fetches the page and therefore never reads the directive.

Then the URL identity and the metadata: how many canonical URLs the page declares — counting both the link element and RFC 8288 Link headers — whether each resolves to an absolute http or https URL, and whether they agree, since two conflicting canonicals mean both are ignored. The title and meta description are checked for presence, for duplicate tags carrying different values, for being identical to each other, and for length against the ranges at which snippets are usually truncated. Those length ranges are a display heuristic and are labelled as one: truncation is measured in pixels, not characters.

Beyond that it checks the heading structure (a missing h1, a level skipped on the way down), images with no alt attribute at all — an empty alt is correct for a decorative image and is counted as such — hreflang annotations for valid language-tag shape and a self-reference, JSON-LD structured data for parse validity and for types that no longer produce a rich result, and Open Graph and Twitter card metadata. For structured data it validates required properties for BreadcrumbList and SoftwareApplication or WebApplication only; Google's Rich Results Test is the right tool for a complete check. Finally it looks at whether /robots.txt exists and is really a robots.txt rather than an HTML error page served with a 200, whether a sitemap can be found, whether it returns XML, and whether robots.txt declares it.

Two limits are worth stating plainly. **No JavaScript is executed.** Everything is read from the served HTML, so a page that sets its title, canonical or meta description in the browser will be reported as missing them. That is a real finding for every consumer that does not render — social and AI crawlers, and Bing's first pass — but it is not necessarily what Google sees after rendering. And it reads one page, the one your address resolves to after redirects: submit http://example.com and the findings may describe https://www.example.com/.

Some smaller behaviours surprise people. Your robots.txt is evaluated for the googlebot token rather than for our own user agent, so a site that allows Googlebot and blocks everything else is reported as crawlable — which is the useful answer. Counts are floors rather than totals when a very large page hits the byte cap, and the run says so instead of reporting a clean pass over a page it only half read. And where a check could not run — a robots.txt that returned a server error, a sitemap that could not be fetched — neither the failure nor the pass is emitted, because silence is more honest than either.

Common questions

Does it crawl my whole site?
No. It reads one page, plus /robots.txt and up to two sitemap URLs. Submit another page's address to check that page. It never fetches the URLs listed inside a sitemap.
Does it check keywords, rankings or backlinks?
No. It checks the technical, on-page signals a search engine reads from your markup and your crawl files. Keyword research, rank tracking and backlink analysis are a different kind of product.
My site is a single-page app and everything is reported missing.
That is accurate for the HTML your server sends, because no JavaScript is executed here. Google renders and will usually see your client-side tags; social, AI and some other crawlers will not. Server-rendering the title, description and canonical fixes it for all of them.
Why does it check a different URL than the one I typed?
It audits the page you actually land on after redirects, since that is the page a search engine indexes. The result names the final URL it read.
Is a missing robots.txt a problem?
No, and it is reported as information rather than a fault. RFC 9309 treats an absent robots.txt as permission to crawl everything. An HTML error page served at that path with a 200 status is a problem, and is reported separately.
Is more than one h1 an error?
No. HTML5 permits it, so it is reported as information, not a fault. A page with no h1 at all is flagged, because the top-level heading is a strong hint about what the page is about.
Why is my title length flagged when it looks fine?
The 15-60 character range is a display heuristic, not a rule: search engines truncate on pixel width, not character count, so a title of wide capitals truncates earlier than the count suggests. The finding says as much and is weighted lightly.
Is FAQ or HowTo structured data still worth adding?
Not for rich results. Google stopped showing FAQ rich results in May 2026 and HowTo in 2023. The markup remains valid schema.org and harmless, so it is reported as information rather than an error.
What this tool checks (33 rules)
  • seo.canonical.conflicting — The page declares more than one canonical URL
  • seo.canonical.invalid — The canonical URL cannot be used
  • seo.canonical.missing — The page declares no canonical URL
  • seo.canonical.points-elsewhere — The canonical URL points at a different page
  • seo.headings.h1-missing — The page has no h1 heading
  • seo.headings.hierarchy-skipped — The heading levels skip a level
  • seo.headings.multiple-h1 — The page has more than one h1
  • seo.hreflang.invalid — An hreflang annotation is not valid
  • seo.hreflang.missing-self-reference — The hreflang set does not include this page
  • seo.html-lang.invalid — The page's declared language is not a well-formed language tag
  • seo.html-lang.missing — The page does not declare what language it is written in
  • seo.images.missing-alt — Images on the page have no alt attribute
  • seo.indexability.blocked-by-robots-txt — robots.txt blocks this page from being crawled
  • seo.indexability.nofollow — The page tells crawlers not to follow its links
  • seo.indexability.noindex — The page asks search engines not to index it
  • seo.indexability.noindex-blocked-by-robots — A noindex directive is unreachable behind a robots.txt block
  • seo.meta-description.length-outside-guidance — The meta description is likely to be truncated or is very short
  • seo.meta-description.missing — The page has no meta description
  • seo.meta.duplicate-tags — The page declares the same metadata more than once
  • seo.meta.title-matches-description — The title and the meta description are identical
  • seo.opengraph.incomplete — Open Graph metadata is missing required properties
  • seo.robots-txt.missing — No robots.txt is published
  • seo.robots-txt.unreadable — The robots.txt at the site root was not readable as directives
  • seo.sitemap.missing — No XML sitemap could be found
  • seo.sitemap.not-declared — The sitemap is not declared in robots.txt
  • seo.sitemap.not-xml — The sitemap URL does not return XML
  • seo.structured-data.absent — The page carries no JSON-LD structured data
  • seo.structured-data.invalid — A JSON-LD block on the page does not parse
  • seo.structured-data.missing-required-property — Structured data is missing a documented required property
  • seo.structured-data.retired-type — Structured data uses a type that no longer produces a rich result
  • seo.title.length-outside-guidance — The title is likely to be truncated or is very short
  • seo.title.missing — The page has no title
  • seo.twitter-card.missing — No Twitter card type is declared