SKILL DETAIL
docs-seo
samber/developer-relations-skills/docs-seo
Runs on-page and technical SEO for a documentation site - indexability, canonicals and hreflang across versioned and translated pages, redirects, the generator's title and description templates, internal linking, and an owner-assigned fix list against an agreed pass threshold. Use whenever someone says "docs SEO", "our docs don't rank", "documentation search optimization", "the old version of our docs outranks the current one", "our docs pages aren't indexed", "fix the canonicals on our docs", "docs sitemap problems", "hreflang on our translated docs" or "preview docs got indexed" - even if they never say SEO. Covers versioned, translated and auto-generated reference docs. Not keyword research - use samber/developer-relations-skills@developer-keyword-research.
Installation
npx skills add https://github.com/samber/developer-relations-skills --skill docs-seo
スキルファイル
SKILL.md
最終同期 · 2026/10/23
evals/evals.json›
{
"skill_name": "docs-seo",
"evals": [
{
"id": 1,
"prompt": "Kelvinstack here. We ship a data-pipeline runtime and our docs are MkDocs Material on docs.kelvinstack.io. We publish 1.9, 2.8, 3.6 and 4.2. 4.2 is current, but 2.8 and 3.6 are both under paid LTS contracts until 2029 and between them they are about 40% of docs sessions. URLs look like /2.8/guides/retries/ and have been that way for four years, so plenty of blog posts and Stack Overflow answers link straight into them. Our platform team has a routing freeze on: we cannot add any new path or alias, we can only edit page templates and the mkdocs config. Google is currently indexing all four versions and picking whichever one it likes. What should our canonical setup be? We would like this settled in about five weeks.",
"expected_output": "A recommendation of the per-version indexable posture, with stable-canonical explicitly deleted (not ranked last) because the routing freeze blocks minting a stable alias, latest-only rejected because two supported LTS releases carry real traffic, the standing cost and reversal cost of per-version indexable stated, and a request to confirm the posture before any canonical rules are written.",
"files": [],
"expectations": [
"Recommends the per-version indexable posture: one self-canonical page per version plus a version switcher",
"States that stable-canonical is deleted rather than ranked last, and names the routing freeze or the inability to mint a stable alias as the reason",
"Rejects latest-only because 2.8 and 3.6 are still supported and carry roughly 40% of sessions",
"Names the condition that promotes per-version indexable despite it being the highest-effort posture: supported LTS releases carrying real traffic, or a support commitment to serve each release's own page",
"States that stable-canonical is the default recommendation being overridden here, rather than omitting it from consideration",
"Describes per-version indexable as a standing job whose cost grows with every release, multiplying the indexable surface, the sitemap and the link mesh",
"States that reversing per-version indexable requires a per-page redirect map across every version already indexed",
"Does not recommend applying noindex to 2.8 or 3.6",
"Requires the sitemap, the canonical tags and the navigation or internal links to all point at the same target",
"Explains that conflicting signals are why Google picks its own canonical",
"Recommends an in-page banner telling the reader which release they are on and what the current release is",
"Asks the user to confirm the posture before any canonical rules are written"
]
},
{
"id": 2,
"prompt": "Fernbrook. docs.fernbrook.io, Sphinx served from our own nginx. Nine months ago we put Disallow: /v1/ and Disallow: /v2/ in robots.txt and also set a noindex flag in the front matter of every page under both, because we wanted them gone from Google. They are still there, about 830 URLs, and they show up in results with no title and no snippet, just the bare URL. On top of that, someone searched our product name last week and found docs-pr-1142.preview.fernbrook.io in the results. We have per-branch preview builds on public hostnames, around 60 live at any time. Our infra lead's suggestion is to add more Disallow lines for the preview hosts. Tell us what to do.",
"expected_output": "An explanation that a disallowed URL is never crawled so its noindex is never read, instructions to remove the Disallow lines so the directive can be read or to redirect instead, rejection of more robots.txt rules as the preview fix in favour of an X-Robots-Tag header at the CDN verified against the live response, and both items placed as blocking defects at the top of the fix list.",
"files": [],
"expectations": [
"Explains that a URL disallowed in robots.txt is never crawled, so its noindex directive and its canonical are never read",
"Connects that rule to the observed symptom: the 830 URLs stay indexable as bare URLs with no title or snippet",
"Instructs removing the Disallow: /v1/ and Disallow: /v2/ lines so the noindex can be read, or redirecting those URLs instead",
"Classifies the robots.txt plus noindex combination as a blocking defect rather than a ranked improvement",
"Rejects the infra lead's proposal to add more robots.txt Disallow lines as the fix for the preview hosts",
"Prescribes serving X-Robots-Tag: noindex for the preview hosts at the CDN or the server",
"States that robots.txt may be used for the preview hosts only as a second layer, never as the only one",
"Gives a command that checks the actual response header, such as curl -sI on a preview URL filtered for x-robots-tag, rather than trusting the platform's defaults",
"Notes that self-hosted or per-branch preview environments usually do not send a noindex header by default, unlike managed platforms",
"Places both defects at the top of the fix list, above any page-signal or internal-linking work",
"States that blocking defects override any overall score the rest of the audit produces"
]
},
{
"id": 3,
"prompt": "Voltaic Systems. Our docs are Docusaurus on docs.voltaic.systems, 1,240 pages, and Google rewrites the title on most of them: we see things in the search results that we never wrote. Our current titles are all shaped like 'Voltaic Docs - Guides - Webhooks - Sending a webhook'. A contractor quoted us for a script that trims every title to 58 characters, plus adding 'Voltaic' to the start of every h1 so the brand is reinforced. Budget is approved, we just want a sanity check before we run it.",
"expected_output": "A rejection of both the trimming script and the h1 branding, an explanation that Google states no title length limit and that rewriting is triggered by an unhelpful breadcrumb-shaped template, and a single template change in the generator config putting the page subject first and the brand last.",
"files": [],
"expectations": [
"Rejects the 58-character trimming script",
"States that Google specifies no limit on the length of a title element",
"Explains that the title link is truncated in results to fit the device width, so a 50-60 character rule is lint rather than a pass/fail gate",
"Identifies the breadcrumb-shaped, brand-first title template as the reason Google is rewriting the title link",
"States that shortening the titles will not stop the rewriting",
"Prescribes a title template change in the Docusaurus config as one fix that reaches all 1,240 pages",
"Gives the target template shape with the specific page subject or searched term first and the product or brand last, for example {page} | {product} {section}",
"Rejects adding the product name to the start of every h1, and explains that it stops the h1 describing the page and hands the rewriter another unhelpful source",
"Names at least two of the sources Google may build a replacement title link from: headings, og:title, prominent styled text, other page text, inbound anchor text, or WebSite structured data",
"States that per-page title rewrites come after the template fix and only for pages that carry traffic",
"Does not propose hand-rewriting all 1,240 titles page by page"
]
},
{
"id": 4,
"prompt": "Lumendra. Docs at docs.lumendra.co, VitePress. English plus French and Japanese, and we launched Japanese three weeks ago. Right now every /fr/ and /ja/ page has a rel=canonical pointing at the matching /en/ page, which our previous agency set up so we would not get duplicate content penalties. When we shipped /ja/ we generated its alternate links, but we did not touch the French pages. French organic traffic is basically zero and Japanese has never appeared at all. We also have a Portuguese set that came out of a machine translation run last year that nobody has ever reviewed. What is wrong?",
"expected_output": "A diagnosis that cross-locale canonicals delete the translations from search, a prescription of self-canonical plus a complete bidirectional hreflang set with x-default, an instruction to regenerate the whole mesh across every locale after the Japanese launch, and a recommendation to noindex the unreviewed machine-translated Portuguese set.",
"files": [],
"expectations": [
"Identifies the canonical from /fr/ and /ja/ to /en/ as the reason the translations do not appear, and states that it removes them from search",
"Prescribes a self-referencing canonical on every locale's pages",
"Requires a complete hreflang set in which each language version lists itself as well as all the others",
"Requires an x-default entry for the case where no other language or region matches",
"States that hreflang return links must be bidirectional, and that missing return links may cause the annotations to be ignored",
"Identifies the Japanese launch without regenerating the French alternates as a return-link failure",
"Instructs regenerating the whole hreflang mesh across every locale whenever a locale is added or removed, not just the new locale's pages",
"Reports the hreflang breaches as defects rather than as optional suggestions or improvements",
"Recommends applying noindex to the unreviewed machine-translated Portuguese set",
"Explains that unreviewed machine translation competes with the English page while satisfying nobody",
"Rejects the duplicate-content-penalty rationale offered for the cross-locale canonicals",
"Notes that a page canonicalized elsewhere has its hreflang annotations discarded, so the set belongs on canonical URLs only"
]
},
{
"id": 5,
"prompt": "Following up on our multilingual docs. Ardenvale, docs.ardenvale.io, Astro Starlight. Here is the hreflang set we are about to ship, generated straight from our locale config: eng, en-uk, jp, fr-FR, es-LA, zh, pt, and EU for our EU-region landing pages. We emit the same set on both v3 and the archived v2. Our platform lead read that Google recommends HTTP headers over sitemap entries once you are past a few thousand pages, so we are planning to move the annotations into the response headers. Anything we should fix before this goes out?",
"expected_output": "A code-by-code validation flagging eng, en-uk, jp, es-LA and EU as invalid with the correct replacements, a note that zh is valid but ambiguous, a correction of the claim that Google prefers HTTP headers, and an instruction to emit the set for canonical versions only.",
"files": [],
"expectations": [
"Flags eng as invalid and states the value must be an ISO 639-1 language code, giving en",
"Flags jp as invalid for Japanese and gives ja",
"Flags en-uk as invalid and gives en-GB as the correct ISO 3166-1 Alpha 2 region",
"Flags EU as an invalid region value",
"Flags es-LA as invalid",
"States that a region code with no language prefix is invalid",
"Notes that zh is valid but ambiguous and recommends an ISO 15924 script subtag such as zh-Hans or zh-Hant",
"Rejects the claim that Google recommends HTTP headers for large sites",
"States that HTML tags, HTTP headers and sitemap entries are equivalent delivery methods with no stated Google preference, large site or not",
"If it still recommends sitemap delivery, justifies it on operational grounds such as head weight on a locale by version tree, not on ranking or on a Google preference",
"Instructs emitting the hreflang set for canonical versions only, and removing it from the archived v2",
"Explains that annotating archived versions creates set members that are themselves canonicalized away"
]
},
{
"id": 6,
"prompt": "Prismvale. An SEO agency just handed us a three-sprint proposal for our developer docs: HowTo structured data on our 310 how-to pages, FAQPage on the 94 pages that have a questions section, and Article markup everywhere else. They are quoting a rich-result uplift from it. Our docs are Mintlify, 1,800 pages. Our engineering manager wants to know whether this is worth three sprints of a technical writer's time.",
"expected_output": "A rejection of the HowTo and FAQPage work because both rich results are gone, a recommendation of BreadcrumbList instead, a note that Article-family markup is harmless but low value, and a redirection of the three sprints toward title and page-signal work.",
"files": [],
"expectations": [
"Advises against building HowTo structured data and states that Google removed the HowTo rich result in August 2023",
"Advises against building FAQPage structured data and states that it stopped appearing entirely, having been narrowed to government and health sites in 2023",
"Rejects the quoted rich-result uplift for HowTo and FAQPage as unavailable",
"Recommends BreadcrumbList markup instead and notes that it still renders",
"Gives the reason BreadcrumbList is worth it on a docs site: docs have a real hierarchy to express",
"Describes TechArticle or Article markup as harmless but low value, with no docs-specific rich result",
"Limits SoftwareApplication or Organization markup to the product pages, applied once",
"States that correct titles plus breadcrumbs beat a schema campaign on a docs site",
"Redirects the three sprints toward the title template and other page-signal work instead of the schema project",
"Does not offer HowTo or FAQPage as a reduced-scope or partial option"
]
},
{
"id": 7,
"prompt": "Osmiq. We generate our API reference from OpenAPI and it is now 62,400 pages, because we emit one page per field on every schema plus one URL per SDK language (we ship 6, and the prose is byte-identical across all of them, only the code block changes). A consultant told us to noindex the whole /reference/ tree so Google does not penalise us for thin content. Separately, our sitemap is a single sitemap.xml with all 62,400 URLs, 71MB, which we gzip down to 4MB before serving. Search Console keeps saying it could not fetch it. What is the right call here?",
"expected_output": "A refusal to blanket-noindex the reference tree, a split to one page per resource rather than per field, a single shared prose page with a language switcher, and a sitemap split into an index because the file breaches the 50,000 URL and 50MB uncompressed limits despite being gzipped.",
"files": [],
"expectations": [
"Rejects blanket-noindexing the generated reference tree",
"States that the generated reference pages are the highest-intent pages on the domain even though they are the thinnest",
"Instructs splitting to one page per resource or command rather than one page per field",
"Recommends a single shared prose page with a language switcher instead of one URL per SDK language, given the prose does not differ per language",
"States the sitemaps.org limit of 50,000 URLs per sitemap file",
"States the 50MB uncompressed byte ceiling per sitemap file",
"States that gzip is allowed but the uncompressed size is what counts, so serving a 4MB gzipped file does not bring the 71MB sitemap into spec",
"Instructs splitting the sitemap into a sitemap index",
"Notes that a sitemap index is itself capped at 50,000 sitemaps under the same byte ceiling",
"Classifies the out-of-spec sitemap as a defect rather than as a negotiable threshold",
"Recommends stable id anchors per symbol so long reference pages can earn anchor-level results and be deep-linked from the product",
"Limits written meta descriptions to the highest-traffic reference entries and lets the rest fall back rather than generating filler"
]
},
{
"id": 8,
"prompt": "Carrowmore. Our board asked us to block the AI scrapers after a legal review, so infra added Disallow lines for every AI user-agent they could find a name for, about 20 of them, in docs.carrowmore.com/robots.txt. Two months later our head of marketing is furious, because when she asks any assistant about our API it says it cannot find our documentation and our competitor gets quoted instead. We are also on a CDN where somebody enabled a 'block AI bots' toggle in the dashboard, which we only noticed last week. Legal still wants training use blocked. How do we untangle this?",
"expected_output": "A per-agent policy rebuild that separates search-style discovery, user-directed fetch and model training, preserves the training block at no search cost, restores the other two, audits the CDN toggle alongside robots.txt, and treats the whole thing as a policy call rather than a blocking defect.",
"files": [],
"expectations": [
"Separates the crawler controls into three categories: search-style discovery, user-directed fetch, and model training",
"States that blocking model training carries no search-visibility cost and is a licensing and policy decision rather than an SEO one",
"Identifies the blocked search-style discovery agents as the reason the docs disappear from assistant answers",
"Identifies the blocked user-directed fetch agents as the reason an assistant cannot retrieve a page a user explicitly asks about",
"Preserves legal's training block while restoring discovery and user-directed fetch access",
"Instructs resolving each named agent's category against that vendor's own current crawler documentation rather than inferring the category from the agent name",
"Instructs auditing the CDN or bot-management layer alongside robots.txt, and notes that a rule can live there that nobody on the docs team chose",
"States that robots.txt-family signals are honour-system, so they are not enforcement against a non-compliant crawler",
"Treats the crawler policy as a policy call rather than as a blocking defect",
"Reports the cost of each rule per agent instead of giving a single blanket allow or block recommendation",
"Stops at crawler access and does not prescribe what to publish for assistants or how to earn citations",
"Does not present llms.txt or a markdown endpoint as a ranking or citation factor"
]
},
{
"id": 9,
"prompt": "Thessaly. We are pulling v2 of our docs offline next month, 1,900 URLs under /v2/. The plan our web team wrote up is a single nginx rule 301ing everything under /v2/ to https://docs.thessaly.dev/, and deleting the v2 entries from the sitemap in a follow-up ticket next quarter. About 160 of those pages document two subsystems we removed entirely in v3, so there is nothing equivalent for them. v2 has a published end-of-life date of 2027-03-31. Does the plan hold up?",
"expected_output": "A rejection of the blanket redirect to the docs home as a soft 404, a per-page target hierarchy with 301 where an equivalent exists and 410 only for the genuinely removed subsystems, the sitemap updated in the same deploy, and redirects retained for at least one major release cycle.",
"files": [],
"expectations": [
"Rejects the blanket 301 of every /v2/ URL to the docs home",
"States that a blanket redirect to the docs index is treated as a soft 404 and loses the page's accumulated history",
"Prescribes choosing a redirect target per page in the order: current equivalent, then nearest equivalent, then the version index only as a last resort",
"Prescribes 301 where an equivalent page exists",
"Prescribes 410 only for the roughly 160 pages whose subsystems are genuinely gone with no successor",
"Rejects deferring the sitemap update to the following quarter and requires it in the same deploy as the redirects",
"States that the redirects must be kept for at least one major release cycle",
"Ties that retention to inbound traffic that outlives the version, such as external tutorials, cached answers and issue threads",
"Warns against canonicalizing a removed or rewritten page to a current page that says something different, and prescribes a redirect to the nearest real equivalent instead",
"Mentions unavailable_after as a directive that fits a version with a published end-of-life date",
"Recommends an in-page banner telling readers which release they are on and what the current release is"
]
},
{
"id": 10,
"prompt": "Nyleve. I run docs for a fintech infrastructure company and I have a board deck on Thursday. What I need out of this audit is a single 'docs SEO health' number out of 100 that I can put on a slide, plus a projected traffic lift percentage for the next two quarters so I can justify the headcount ask. The site is docs.nyleve.io, Hugo, 2,400 pages, no versioning, English only. Give me the audit in whatever shape you like as long as I get those two numbers.",
"expected_output": "A refusal of both the composite score and the traffic projection, with agreed numeric pass thresholds offered in their place, plus the report delivered in the prescribed section order with a ranked fix list whose ordering ratio and owner column are stated explicitly.",
"files": [],
"expectations": [
"Refuses to produce a single docs SEO health score out of 100",
"Refuses to produce a projected traffic lift percentage",
"Explains that re-indexing, re-ranking and re-snippeting happen on the search engine's schedule, so ranking outcomes are reported as pending rather than promised",
"Offers agreed numeric pass thresholds, set before the fix pass, as the substitute for the score",
"Lists the report sections in order: verdict, coverage statement of what could not be verified, blocking defects, version and translation posture, page signals, internal linking, then the ranked fix list and thresholds",
"States the fix-list ordering as (pages affected x severity) divided by effort",
"Says out loud in the report that the ordering is a ratio and not a severity list",
"Places blocking defects at the top of the fix list regardless of their effort score",
"Gives every fix row a named owner plus the file or system that changes",
"Names what the ratio starves, a fix that touches few pages and takes a week, and states that such a fix must be promoted by hand",
"Presents the numeric baselines as this skill's own defaults to be adjusted to the site rather than as industry standards",
"Keeps search-engine-enforced or spec-enforced requirements out of the negotiable threshold table and in the blocking-defects section",
"Proposes re-running the crawl after the fixes land and diffing the two summaries as the proof that the work landed"
]
},
{
"id": 11,
"prompt": "Grantham Data. Docs at docs.granthamdata.com, 900 pages, Docusaurus. We sell mostly to banks and insurers, six-figure annual contracts, and every deal goes through a security review before it closes. Our analytics say our top pages are the quickstart, the Python SDK install page and three how-tos, so that is where I want the SEO effort concentrated. Our crawl also found 47 pages with no inbound links at all, and a lot of reference pages that link nowhere. Where do we start?",
"expected_output": "A challenge to the traffic-ranked prioritisation that names the company-evaluation page set explicitly, plus an internal-linking sequence of orphans, then dead ends, then contextual and cross-mode links sourced from the high-authority pages, with a cannibalization check in the same pass.",
"files": [],
"expectations": [
"Challenges concentrating the effort on the traffic-ranked page list",
"Names the company-evaluation page set explicitly: limits, security, compliance, self-hosting and migration pages",
"States that those pages are usually the thinnest, least-linked and least-indexed pages on the site",
"States that a traffic-ranked page list will never surface that set, so it has to be checked explicitly",
"Orders the internal-linking work as orphans first, then dead ends, then contextual links",
"Requires every one of the 47 orphans to be either linked from somewhere or deleted",
"Sources the new contextual links from the highest-traffic pages, such as the quickstart, the top how-tos and index pages, because those carry the authority to pass",
"Prescribes cross-mode links: tutorial to reference for the symbols it used, reference to how-to for the task it serves, troubleshooting to the guide where the failure happens",
"Requires descriptive anchor text and rejects 'here', 'this page' or 'read more'",
"States that generated Next and Previous pagination links do not count as contextual links",
"Checks cannibalization against the company's own blog in the same pass and makes the losing surface link to the winner rather than deleting it"
]
},
{
"id": 12,
"prompt": "Belvoir. I am building the measurement side of our docs SEO work. The plan: hit the Search Console Search Analytics API, filter to queries at average position 5 through 20 so we get the 'almost there' set, pull them with the default response, and sum the impressions to get our total docs search demand. Then I want to compare that total against the volume numbers from a keyword tool so we can show the gap to our VP. Site is docs.belvoir.dev, about 3,000 pages across /docs/, /reference/ and /blog/. Sound workable?",
"expected_output": "A correction that the API has no position filter and returns average position only as a metric, plus the rowLimit default, the withheld rare queries, a refusal to sum query rows into a site total, rejection of the keyword-tool comparison, and the segmentation and Pages-report reading the measurement should use instead.",
"files": [],
"expectations": [
"States that the Search Analytics API has no position filter",
"Lists the groupable and filterable dimensions as country, device, page, query, searchAppearance, date and hour",
"Explains that average position comes back as a metric, so a positions 5-20 shortlist has to be filtered client-side after a large export",
"States that rowLimit accepts 1 to 25,000 and defaults to 1,000, so the default response is the head of the list only",
"States that the API does not guarantee returning all data rows, only top ones",
"States that rare queries are withheld for privacy",
"Explains that the withheld tail is exactly the long tail of error strings and symbol names that matters on a technical property",
"Rejects summing query rows into a site total, and says the visible tail should be treated as a floor",
"Rejects the keyword-tool volume comparison as a benchmark, noting that most technical queries report as zero and third-party estimates are directional at best",
"Requires segmenting by path prefix, one filter set each for /docs/, /reference/ and /blog/, before reading anything",
"Gives the reason for segmenting: a sitewide average hides opposite patterns per section, such as reference pages earning impressions without clicks while guides do the reverse",
"Instructs reading the Pages report against the sitemap in both directions: in the sitemap but not indexed is the indexation gap, indexed but absent from the sitemap is the leak"
]
},
{
"id": 13,
"prompt": "Ridgeline. Docs are Sphinx on Read the Docs, and both ridgeline.readthedocs.io and docs.ridgeline.sh return 200 with identical content, both in Google. Our new head of growth wants to move the whole thing to ridgeline.sh/docs because a subfolder inherits the main domain's authority. That is a 4,000-page migration and I am not thrilled about it. Also worth mentioning: we swapped the theme for a custom React front end last year, so the page body, the canonical tag and the sidebar nav are all injected by JavaScript, and site search is a widget with no plain links. Traffic has been sliding since. Where do I push back?",
"expected_output": "A primary-domain decision canonicalizing the platform host at the custom domain, a rejection of the subfolder migration as not worth its cost, and the JavaScript-injected body, canonical and navigation identified as the blocking defect that outranks every other fix.",
"files": [],
"expectations": [
"Prescribes choosing docs.ridgeline.sh as the primary domain, canonicalizing the readthedocs.io host at it, and redirecting where the platform allows",
"Requires the sitemap to list the custom-domain URLs rather than the platform subdomain",
"Advises against the ridgeline.sh/docs migration, and states that migrating an established docs site costs more than the subdomain-versus-subfolder difference is worth",
"Identifies the JavaScript-injected body text, canonical tag and navigation links as a blocking defect",
"Places the rendering defect above title, description and internal-linking work in the fix order",
"Prescribes fetching a page with JavaScript disabled to confirm the body text, the canonical link and the internal links are already present in the HTML",
"Notes that a search widget owning navigation leaves whole sections with no crawlable anchor href path",
"States that Read the Docs can supply the canonical URL to the build, for example through READTHEDOCS_CANONICAL_URL",
"Notes that the server-side rendering fix touches few pages and takes real engineering time, so a (pages affected x severity) divided by effort ordering starves it and it must be promoted by hand",
"Does not recommend the 4,000-page URL migration as the remedy for the traffic decline",
"Starts the crawl set from the sitemap and treats any gap between the sitemap and reality as a finding in its own right"
]
}
],
"trigger_queries": [
{ "query": "audit our docs site for SEO", "should_trigger": true },
{ "query": "our documentation doesn't rank for anything", "should_trigger": true },
{ "query": "Google keeps showing our v1 docs instead of v3", "should_trigger": true },
{ "query": "half our API reference pages aren't indexed", "should_trigger": true },
{ "query": "fix the canonicals on our docs", "should_trigger": true },
{ "query": "our staging docs are showing up in Google", "should_trigger": true },
{ "query": "hreflang on our translated docs is broken", "should_trigger": true },
{ "query": "we rewrote the docs and traffic dropped", "should_trigger": true },
{ "query": "why does an old release of our documentation outrank the current one", "should_trigger": true },
{ "query": "docs sitemap problems", "should_trigger": true },
{ "query": "our docs titles all look the same in search results", "should_trigger": true },
{ "query": "preview docs got indexed", "should_trigger": true },
{ "query": "documentation search optimization", "should_trigger": true },
{ "query": "docs SEO", "should_trigger": true },
{ "query": "how do I stop Google indexing our archived documentation versions", "should_trigger": true },
{ "query": "Search Console says duplicate, Google chose a different canonical, on 400 docs pages", "should_trigger": true },
{ "query": "should we noindex our auto-generated API reference", "should_trigger": true },
{ "query": "our French docs pages get zero organic traffic", "should_trigger": true },
{ "query": "someone put our whole /v1/ path in robots.txt and the pages are still in Google", "should_trigger": true },
{ "query": "we're retiring v2 of the docs next month, what do we do with the URLs", "should_trigger": true },
{ "query": "Google is rewriting all our documentation page titles", "should_trigger": true },
{ "query": "our docs sitemap has 60,000 urls in one file, is that a problem", "should_trigger": true },
{ "query": "we blocked all the AI crawlers and now assistants can't find our documentation", "should_trigger": true },
{ "query": "our docs are a React shell and nothing seems to get crawled", "should_trigger": true },
{ "query": "we have 300 documentation pages with no inbound links", "should_trigger": true },
{ "query": "should we add HowTo schema to our how-to docs", "should_trigger": true },
{ "query": "our readthedocs.io subdomain and our custom docs domain are both in Google", "should_trigger": true },
{ "query": "how do we set up canonical tags across documentation versions", "should_trigger": true },
{ "query": "every page of our docs has the same meta description", "should_trigger": true },
{ "query": "our docs pages get impressions but no clicks", "should_trigger": true },
{ "query": "make our documentation findable on Google", "should_trigger": true },
{ "query": "the docs for our deprecated release keep coming up first", "should_trigger": true },
{ "query": "how do we pick between /docs on the main domain and a docs subdomain", "should_trigger": true },
{ "query": "our lastmod says every docs page changed on every deploy", "should_trigger": true },
{ "query": "I need a prioritized fix list for our documentation site's search performance", "should_trigger": true },
{ "query": "our blog post and our docs page are competing for the same query", "should_trigger": true },
{ "query": "what pass thresholds should we set for a documentation SEO cleanup", "should_trigger": true },
{ "query": "can you check whether our documentation is crawlable", "should_trigger": true },
{ "query": "we launched Japanese docs and nothing shows up in Japanese search", "should_trigger": true },
{ "query": "are our documentation pages being indexed properly", "should_trigger": true },
{ "query": "every version of our docs is in the sitemap and I think that's wrong", "should_trigger": true },
{ "query": "our generator emits h1 then h4 on every reference page", "should_trigger": true },
{ "query": "developers can't find our error pages through Google", "should_trigger": true },
{ "query": "review the on-page setup of our documentation site", "should_trigger": true },
{ "query": "our docs got migrated to a new generator and rankings tanked", "should_trigger": true },
{ "query": "the PR preview builds of our documentation are public and indexed", "should_trigger": true },
{ "query": "how should we structure titles across our documentation", "should_trigger": true },
{ "query": "our anchor ids changed in the theme upgrade and all the deep links broke", "should_trigger": true },
{ "query": "what should the canonical be for a page that exists in four releases", "should_trigger": true },
{ "query": "our docs pages rank below Stack Overflow copies of our own content", "should_trigger": true },
{ "query": "I want the current release's documentation to win the query", "should_trigger": true },
{ "query": "is our robots.txt hurting our documentation", "should_trigger": true },
{ "query": "we want the docs indexed but not the SDK-language duplicates", "should_trigger": true },
{ "query": "our documentation has six URLs for the same page, one per SDK language", "should_trigger": true },
{ "query": "how do I audit a versioned documentation site for search", "should_trigger": true },
{ "query": "our documentation shows up with just a bare URL and no title in results", "should_trigger": true },
{ "query": "our Mintlify docs aren't getting picked up by Google", "should_trigger": true },
{ "query": "help me write the internal linking plan for our docs site", "should_trigger": true },
{ "query": "our docs home is fast but the reference pages feel slow, does that matter for search", "should_trigger": true },
{ "query": "what do I check before launching a second locale of our documentation", "should_trigger": true },
{ "query": "our security and compliance docs pages get no traffic at all", "should_trigger": true },
{ "query": "can you look at why our developer documentation gets so little search traffic", "should_trigger": true },
{ "query": "we want to be sure the right version of a page is the one people land on", "should_trigger": true },
{ "query": "the docs site scores badly on Core Web Vitals, where do I start", "should_trigger": true },
{ "query": "our sitemap and our canonical tags disagree", "should_trigger": true },
{ "query": "how do we keep archived documentation versions out of search results without breaking links", "should_trigger": true },
{ "query": "I inherited a documentation site and I don't know if its search setup is sane", "should_trigger": true },
{ "query": "what keywords do developers search for when they hit an auth error", "should_trigger": false },
{ "query": "build me a keyword list for our SDK docs", "should_trigger": false },
{ "query": "how much search demand is there for rust async runtime", "should_trigger": false },
{ "query": "mine our docs search logs for what people actually look for", "should_trigger": false },
{ "query": "which docs pages should we create based on search demand", "should_trigger": false },
{ "query": "classify our docs pages against Diátaxis", "should_trigger": false },
{ "query": "our documentation is a mess, help me reorganize it", "should_trigger": false },
{ "query": "where should this new page live in our docs information architecture", "should_trigger": false },
{ "query": "what docs are we missing for our new billing API", "should_trigger": false },
{ "query": "run a CNCF TechDocs rubric score on our documentation", "should_trigger": false },
{ "query": "make our docs readable by coding agents", "should_trigger": false },
{ "query": "should we publish an llms.txt for our documentation", "should_trigger": false },
{ "query": "agents keep inventing API methods that don't exist in our SDK", "should_trigger": false },
{ "query": "could a coding agent wire up our SDK from the docs alone", "should_trigger": false },
{ "query": "write our error code catalog pages", "should_trigger": false },
{ "query": "turn our support tickets into troubleshooting documentation", "should_trigger": false },
{ "query": "we answer the same question every week, where does it go in the docs", "should_trigger": false },
{ "query": "build a tracking plan for our docs analytics", "should_trigger": false },
{ "query": "our GitHub numbers don't match our analytics", "should_trigger": false },
{ "query": "set up UTM discipline across our devrel links", "should_trigger": false },
{ "query": "which devrel KPIs deserve a target this year", "should_trigger": false },
{ "query": "our docs code samples no longer compile", "should_trigger": false },
{ "query": "test the code examples in our documentation in CI", "should_trigger": false },
{ "query": "our Python and Go snippets have drifted apart", "should_trigger": false },
{ "query": "write a quickstart for our API", "should_trigger": false },
{ "query": "nobody finishes our getting started page", "should_trigger": false },
{ "query": "what's a good time to first success target for a CLI", "should_trigger": false },
{ "query": "rewrite the README for this repo", "should_trigger": false },
{ "query": "too many badges on our README", "should_trigger": false },
{ "query": "write a teaching tutorial for our streaming API", "should_trigger": false },
{ "query": "write our release changelog for 4.2", "should_trigger": false },
{ "query": "optimize my GitHub profile page", "should_trigger": false },
{ "query": "write an engineering blog post about our storage migration", "should_trigger": false },
{ "query": "plan our conference CFP submissions for next year", "should_trigger": false },
{ "query": "how do we launch a developer community on Discord", "should_trigger": false },
{ "query": "design our developer ecosystem strategy", "should_trigger": false },
{ "query": "how should we price our developer tool", "should_trigger": false },
{ "query": "map the developer journey for our platform", "should_trigger": false },
{ "query": "segment our developer audience", "should_trigger": false },
{ "query": "plan a live demo for our launch keynote", "should_trigger": false },
{ "query": "should we sponsor this developer conference", "should_trigger": false },
{ "query": "write our open source governance model", "should_trigger": false },
{ "query": "triage the open issues in our repo", "should_trigger": false },
{ "query": "which OSS license should we pick", "should_trigger": false },
{ "query": "audit the SEO of our marketing site", "should_trigger": false },
{ "query": "how do we rank for near me searches in Portland", "should_trigger": false },
{ "query": "optimize our Shopify product pages for search", "should_trigger": false },
{ "query": "build 500 programmatic landing pages for city plus service combos", "should_trigger": false },
{ "query": "get us more backlinks", "should_trigger": false },
{ "query": "write an SEO blog post about payment fraud", "should_trigger": false },
{ "query": "how do we get cited by ChatGPT and Perplexity", "should_trigger": false },
{ "query": "add Product schema to our pricing page", "should_trigger": false },
{ "query": "compress and rename our images for SEO", "should_trigger": false },
{ "query": "configure nuxt-seo on our marketing site", "should_trigger": false },
{ "query": "run an on-page SEO audit of our homepage", "should_trigger": false },
{ "query": "why did our ecommerce category pages lose rankings after the core update", "should_trigger": false },
{ "query": "set up hreflang for our multi-country storefront", "should_trigger": false },
{ "query": "our Google Business Profile isn't showing in maps", "should_trigger": false },
{ "query": "do keyword clustering for our content calendar", "should_trigger": false },
{ "query": "write meta descriptions for our 40 landing pages", "should_trigger": false },
{ "query": "our LCP on the marketing homepage is 4.2 seconds", "should_trigger": false },
{ "query": "set up the translation workflow for our mkdocs site", "should_trigger": false },
{ "query": "which translation vendor should we use for our documentation", "should_trigger": false },
{ "query": "our docs build is slow in CI, can you speed it up", "should_trigger": false },
{ "query": "migrate our documentation from Sphinx to Docusaurus", "should_trigger": false },
{ "query": "write the API reference content for our new endpoints", "should_trigger": false },
{ "query": "set up a docs review workflow so PRs need a writer sign-off", "should_trigger": false }
]
}
references/audit-report-and-metrics.md›
# Audit report, measurement and maintenance
Contents: report template · worked report excerpt · Search Console segmentation · thresholds · maintenance triggers.
## Table of Contents
- [Report template](#report-template)
- [Verdict](#verdict)
- [Coverage of this audit](#coverage-of-this-audit)
- [Blocking defects](#blocking-defects)
- [Duplication and versioning](#duplication-and-versioning)
- [Page signals](#page-signals)
- [Internal linking](#internal-linking)
- [Fix list](#fix-list)
- [Pass threshold](#pass-threshold)
- [Worked excerpt](#worked-excerpt)
- [Blocking defects](#blocking-defects)
- [Search Console segmentation](#search-console-segmentation)
- [Thresholds](#thresholds)
- [Maintenance triggers](#maintenance-triggers)
## Report template
```markdown
# Docs SEO audit: docs.example.com - 2026-08-28
## Verdict
One paragraph: what is blocking search performance, and the single fix
with the largest effect.
## Coverage of this audit
Pages crawled: N (from sitemap / from URL list).
Not verified: <what could not be checked and why - no Search Console access,
no repository access, platform blocked the crawl>.
## Blocking defects
Anything that makes correct pages invisible. These override any score.
| # | Defect | Pages affected | Fix | Owner | Effort |
## Duplication and versioning
Declared posture, actual posture, and the gap between them.
## Page signals
Template-level fixes first (title template, description defaults,
heading levels), per-page fixes second.
## Internal linking
Orphans, dead ends, and the contextual links to add from the
highest-authority pages.
## Fix list
Ordered by (pages affected x severity) / effort. One line per fix:
what changes, in which file, who owns it.
## Pass threshold
The agreed numbers and where each one stands today.
```
## Worked excerpt
```markdown
## Blocking defects
| # | Defect | Pages | Fix | Owner | Effort |
| --- | -------------------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------- | ---------- | ------ |
| 1 | `/v1/` and `/v2/` disallowed in robots.txt, so their `noindex` is never read - 812 URLs indexed URL-only | 812 | remove the Disallow, serve `X-Robots-Tag: noindex` from the CDN | infra | 1h |
| 2 | Sitemap lists every version; 3 of 4 contradict the canonical tag | 1,204 | regenerate from the stable alias only | docs build | 2h |
| 3 | PR preview host returns no `X-Robots-Tag`; 41 preview URLs indexed | 41 | add the header at the CDN, then request removal | infra | 1h |
```
Note the shape: each row names the file or system that changes and who owns it. A defect list with no owner column comes back unchanged at the next audit.
## Search Console segmentation
Search Console is the only source that sees a docs site's real queries. Treat third-party volume estimates as directional at best - most technical queries report as zero.
Segment before reading anything:
- One filter set per path prefix (`/docs/`, `/reference/`, `/blog/`). A sitewide average hides that reference pages get impressions and no clicks while guides get the reverse.
- Filter to the canonical version path; version noise otherwise dominates every report.
- Read the Pages report against the sitemap in both directions: URLs in the sitemap but not indexed is the indexation gap; URLs indexed but absent from the sitemap is the leak (previews, retired versions, the platform subdomain).
- Watch the "Duplicate, Google chose different canonical" bucket specifically - it is the direct readout of whether the version posture is working.
Where Search Console access is unavailable, the crawl script's summary plus a `site:` query per section is a usable substitute for everything except query data. Say so in the report's coverage section rather than filling the gap with estimates.
### Limits of the query data itself
Documented in Google's Search Analytics API reference:
- `rowLimit` accepts 1-25,000 and defaults to 1,000, so an unmodified export is the head of the list only.
- Groupable and filterable dimensions are `country`, `device`, `page`, `query`, `searchAppearance`, `date`, `hour`. **There is no position filter** - average position comes back as a metric, so a "positions 5-20" shortlist has to be filtered client-side after a large export.
- "The API […] does not guarantee to return all data rows but rather top ones."
- Rare queries are withheld for privacy. On a technical property that suppresses exactly the long tail of error strings and symbol names you care about, so treat the visible tail as a floor and never sum query rows into a site total.
- Search Console's own help notes that chart totals and table totals can differ because of property-versus-page aggregation.
## Thresholds
Two different kinds of number end up in this table, and mixing them is how a threshold conversation goes wrong. The first column of the verdict is which kind you are quoting.
**Sourced requirements** are enforced by the search engine or the spec, so they are defects rather than targets. Put them in the blocking-defects section, not here:
- a page disallowed in `robots.txt` never has its `noindex` or canonical read
- an `hreflang` set with a missing return link may be ignored entirely
- a sitemap file may not exceed 50,000 URLs or 50MB uncompressed
- `lastmod` is only used when it is consistently accurate
**Baselines for this skill** - assembled here, not industry standards. Say so when you present them, then agree adjusted numbers with the user before the fix pass so remediation has a finish line:
| Metric | Baseline | Measured with |
| ---------------------------------------------- | ------------- | ----------------------------------------- |
| Canonical docs URLs indexed | ≥ 95% | Search Console Pages report vs. sitemap |
| Pages where Google chose a different canonical | 0 | Search Console Pages report |
| Non-200 URLs in the sitemap | 0 | crawl script |
| Docs pages with a unique title | 100% | crawl script |
| Orphan pages (no inbound internal link) | 0 | crawl script |
| Indexed preview / retired-version URLs | 0 | `site:` query per host and version prefix |
| Core Web Vitals passing for the docs path | ≥ 90% of URLs | Search Console Core Web Vitals report |
Only the Core Web Vitals row has a sourced component: each metric's own pass mark is Google's, while the share of URLs required to reach it is this skill's baseline.
Re-run the crawl after the fix pass and compare the two summaries. That diff is the deliverable's proof, not the original findings list.
Report ranking and indexation outcomes as pending rather than promised. Configuration and consistency are verifiable in the same session. Re-indexing, re-ranking and snippet changes are not, and no ranking-factor weight quoted in an audit is measurable by the person quoting it.
Two numbers not worth targeting: bounce rate (a developer who found the answer and left is a success) and pages per session on reference content.
## Maintenance triggers
Book these now - a docs site regresses through routine releases, not neglect:
- **A new version ships**: the canonical alias moves, the sitemap regenerates, the previous version's banner appears.
- **A version is retired**: per-page redirect targets chosen, sitemap updated in the same deploy.
- **The docs generator or theme is upgraded**: re-check the title template, the sitemap plugin and the anchor-id scheme - theme upgrades silently reset all three.
- **A new locale launches**: `hreflang` set regenerated across every locale, not just the new one.
- **Quarterly**: re-run the crawl, diff against the last summary, check the "different canonical" bucket.
If your environment has persistent memory, store the durable decisions:
- the version posture and its canonical target
- the chosen primary domain
- the title template
- the agreed thresholds
- which surface owns which query family
The next pass is a diff against those, and re-deriving them each cycle is where this work usually dies.
references/indexability-and-versioning.md›
# Indexability, versioning and retirement
Contents: robots directives · AI and assistant crawlers · sitemaps per generator · sitemap size limits · preview and staging leakage · rendering · canonical rules platforms don't enforce · translations and hreflang validation · mirrored domains · retiring a version · generated reference trees.
## Table of Contents
- [Robots directives](#robots-directives)
- [AI and assistant crawlers](#ai-and-assistant-crawlers)
- [Sitemaps](#sitemaps)
- [Preview and staging leakage](#preview-and-staging-leakage)
- [Rendering](#rendering)
- [Version canonical postures](#version-canonical-postures)
- [Translations and hreflang validation](#translations-and-hreflang-validation)
- [Mirrored domains](#mirrored-domains)
- [Retiring a version](#retiring-a-version)
- [Generated reference trees](#generated-reference-trees)
## Robots directives
Supported as a `<meta name="robots">` tag or an `X-Robots-Tag` response header (the header is the only option for non-HTML files such as PDF exports or an OpenAPI JSON):
| Directive | Effect |
| ------------------------------------------------------ | ------------------------------------------------------- |
| `noindex` | keep the URL out of results |
| `nofollow` | don't follow this page's links |
| `nosnippet` / `max-snippet:[n]` | suppress or cap the text snippet |
| `data-nosnippet` (HTML attribute) | exclude one block from the snippet |
| `max-image-preview:[setting]`, `max-video-preview:[n]` | cap media previews |
| `noimageindex`, `notranslate`, `indexifembedded` | narrower controls |
| `unavailable_after:[date]` | drop after a date - fits a version with a published EOL |
The rule that costs docs teams the most: a URL disallowed in `robots.txt` is never crawled, so its `noindex` and its canonical are never read. Blocking `/v1/` in `robots.txt` leaves those URLs indexable as bare URLs forever. Allow the crawl and serve `noindex`, or redirect.
Never block CSS or JS in `robots.txt` - the renderer needs them.
## AI and assistant crawlers
Audit these per agent and report the cost of each rule. A single blanket rule against "AI bots" is the mistake to catch. Three categories behave differently and are usually served by different named agents:
| Category | What it controls | Cost of blocking a docs site |
| ---------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Search-style discovery | whether the docs can be surfaced and quoted in an assistant's answer | the docs disappear from the answers the company most wants to appear in |
| User-directed fetch | whether an assistant may fetch a page a user explicitly pasted or asked about | a developer asking their assistant about your docs gets nothing back |
| Model training | whether the content may be used to train a model | no search-visibility cost; this is a licensing and policy decision, not an SEO one |
Rules to apply rather than a name list, because vendors rename agents and add new ones:
- Read every `User-agent` block in `robots.txt` and resolve which category each named agent belongs to against that vendor's own current crawler documentation. Never infer the category from the name.
- Some crawler controls are explicitly scoped to non-search uses and have no effect on ordinary search inclusion; check each one's documented scope before reporting it as a visibility problem.
- Check the CDN, WAF or bot-management layer as well as `robots.txt`. Some platforms block AI crawlers by default for new sites, which produces a policy nobody in the docs team chose and nobody can find in the repository.
- `robots.txt`-family signals are honour-system: a compliant crawler obeys them, a non-compliant one is only stopped at the edge. Don't present a robots rule as enforcement.
- Machine-readable surfaces (`llms.txt`, markdown endpoints, published specs) are a publishing decision, not a ranking one. Nothing about them is a ranking or citation factor, and they must not duplicate the sitemap's job. Route that work to samber/developer-relations-skills@coding-agent-docs-optimization.
## Sitemaps
Where the sitemap comes from, per generator:
| Generator | Sitemap source |
| --------------------------- | ------------------------------------------------------------------ |
| MkDocs / Material | built into `mkdocs build` |
| Docusaurus | `@docusaurus/plugin-sitemap` (on by default in the classic preset) |
| Sphinx / Read the Docs | `sphinx-sitemap` extension |
| VitePress | `sitemap` key in `.vitepress/config.*` |
| Astro Starlight | `@astrojs/sitemap` |
| Next.js / Nextra | a `sitemap` route handler, or `next-sitemap` |
| Hugo | built in |
| Mintlify / GitBook / ReadMe | generated by the platform |
The three recurring defects:
1. Every version is listed instead of the canonical one, so the sitemap contradicts the canonical tags.
2. URLs use the platform subdomain rather than the custom domain.
3. `lastmod` is the build timestamp, so every page looks edited on every deploy. Google only trusts `lastmod` when it is consistently accurate - wire it to the source file's last commit date, or omit it.
Google retired the sitemap ping endpoint in 2023. Submit through Search Console and let the crawler pick up changes.
Size limits come from the sitemaps.org protocol, which a large reference tree does reach:
- one sitemap file: no more than 50,000 URLs, under 50MB uncompressed (52,428,800 bytes)
- a sitemap index: no more than 50,000 sitemaps, under the same byte ceiling
Gzip is allowed, but the uncompressed size is what counts. Above either limit, split into an index - several generators emit one flat file regardless of page count and will breach the cap without warning.
## Preview and staging leakage
Branch previews, PR previews and staging hosts are the most common source of accidentally indexed docs. Managed platforms usually send `X-Robots-Tag: noindex` on preview URLs by default. Self-hosted preview environments and per-branch docs builds usually do not.
Verify with the actual response, not the platform's reputation:
```bash
curl -sI https://docs-pr-482.preview.example.com/ | grep -i x-robots-tag
```
If nothing comes back, add the header at the CDN or the server, and add the host to `robots.txt` only as a second layer - never as the only one, since a blocked page can still be indexed URL-only.
## Rendering
Test with JavaScript disabled and confirm the body text, the canonical link and the internal links are already in the HTML:
```bash
curl -s https://docs.example.com/guides/webhooks | grep -c "rel=\"canonical\""
```
Client-rendered docs shells, lazily hydrated page bodies, tab panels holding the per-language sample, and API explorers that fetch a spec at runtime all risk arriving after the render pass. Two related traps: content behind a tab or accordion is indexed but rarely the reason a page ranks, and a search widget that owns navigation leaves whole sections with no crawlable `<a href>` path.
## Version canonical postures
The three postures, their effort and their ranking are in `SKILL.md` Step 3, where the choice is actually made. Mixing them produces the conflicting signals Google resolves on its own. What follows are the two rules no platform enforces for you, whichever posture you picked:
- A canonical must point at a page that genuinely says the same thing. If the page was removed or rewritten between versions, redirect it to the nearest real equivalent instead; Google ignores a canonical to unrelated content.
- Ship an in-page banner ("You are reading v1.4; the current release is 3.0") whatever the posture. It fixes the human half of the problem, which the canonical does not.
## Translations and hreflang validation
Each locale self-canonicalizes and carries a full `hreflang` set including `x-default`. Canonicalizing `/fr/` to `/en/` deletes the French docs from search. The one exception worth `noindex`ing is unreviewed machine translation: it competes with the English page and satisfies nobody.
Requirements stated in Google's own localized-versions documentation - report a breach as a defect, not a suggestion:
| Rule | Google's wording, condensed |
| -------------- | ----------------------------------------------------------------------------------- |
| Self-reference | each language version lists itself as well as all the others |
| Return links | if page X links to page Y, Y must link back to X, or the annotations may be ignored |
| `x-default` | the reserved value used when no other language/region matches the user's setting |
| Code format | ISO 639-1 language, optionally followed by an ISO 3166-1 Alpha 2 region |
| Delivery | HTML tags, HTTP headers and sitemap entries are equivalent; pick the convenient one |
Practitioner checks that Google does not state, but that catch real breakage - present them as heuristics:
- Keep `hreflang` on canonical URLs only; a page canonicalized elsewhere has its annotations discarded.
- Match protocol and trailing-slash form exactly across the set; a mismatch is a different URL.
- Prefer sitemap delivery for a locale × version tree, on `<head>` weight alone - hundreds of tags per page is an operational problem, not a ranking one.
- Emit the set for canonical versions only. Annotating archived versions creates members that are themselves canonicalized away.
Invalid values seen most often:
- `eng` (ISO 639-2)
- `jp` for Japanese (`ja`)
- `en-uk` (the region code is `GB`)
- `EU` or `UN` as a region
- `es-LA` for Latin America
- a region with no language prefix
`zh` is valid but ambiguous - use the ISO 15924 script subtag (`zh-Hans`, `zh-Hant`), which may itself carry a region (`zh-Hans-US`).
Regenerate the whole mesh whenever a locale is added or removed. Adding `/ja/` without updating `/fr/`'s alternates leaves `/fr/` advertising an incomplete set, which is a return-link failure for the new locale.
## Mirrored domains
Docs often resolve on both a platform host (`project.readthedocs.io`, `org.github.io`, `project.pages.dev`) and a custom domain. Choose the custom domain, canonical the platform host at it, redirect where the platform allows, and make the sitemap agree.
The `docs.example.com` vs `example.com/docs` question is a weaker, separate decision - a subfolder inherits the main domain's authority, a subdomain is easier to host independently. Treat it as infrastructure. Migrating an established docs site costs more than the difference is worth.
## Retiring a version
1. Decide the redirect target per page, not per tree: current equivalent → nearest equivalent → the version index. A blanket redirect of every old URL to the docs home is treated as a soft 404 and loses the page's history.
2. `301` where an equivalent exists; `410` only for content that is genuinely gone with no successor.
3. Keep the redirects for at least one major release cycle - external tutorials, cached answers and issue threads keep sending traffic long after the version dies.
4. Update the sitemap in the same deploy, or the sitemap keeps advertising URLs that now redirect.
## Generated reference trees
Auto-generated API/CLI reference produces thousands of thin, near-identical pages that are also the highest-intent pages on the site. Don't blanket-`noindex` them. What works instead:
- One page per resource or command, not per field or flag.
- One shared prose page with a language switcher rather than one URL per SDK language, unless the prose actually differs per language.
- Stable `id` anchors per symbol so a long page can earn anchor-level results and be linked from the product.
- A written description on the highest-traffic entries only; the rest can fall back to a generated one.
references/page-signals-and-snippets.md›
# Page signals: titles, descriptions, headings, links, structured data
Contents: title templates · worked title examples · descriptions and snippet controls · headings and anchors · internal linking · structured data status · cannibalization.
## Title templates
On a docs site the title is generated, so fix the template once and every page inherits it. Find it in the generator config:
| Generator | Title config |
| ---------- | ------------------------------------- |
| MkDocs | `site_name` plus a theme title format |
| Docusaurus | `titleDelimiter`/`title` |
| VitePress | `titleTemplate` |
| Starlight | `title` |
| Sphinx | `html_title` |
| Mintlify | `name` field in `docs.json` |
Target shape: `{specific task or symbol} | {product} {section}` - the searched term first, the brand last, nothing between them.
| Weak | Strong | Why |
| --------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------- |
| `Documentation - Guides - Webhooks - Sending` | `Send a webhook \| Acme Docs` | breadcrumb-as-title puts the query term fourth |
| `Acme Docs` (every page) | one title per page | duplicate titles collapse in results |
| `createClient - API` | `createClient() \| Acme SDK Reference` | the query is the exact symbol, parentheses included |
| `Webhooks (v2.3.1)` | `Send a webhook \| Acme Docs` | patch version in the title dates the snippet and burns characters |
| `Everything you need to know about webhooks` | `Send a webhook \| Acme Docs` | blog voice on a task page |
Include the version only when versions are separately indexable.
### Length and rewriting: what the rule actually is
Google states there is no limit on how long a `<title>` element can be - the title link is truncated in search results as needed, typically to fit the device width. So 50-60 characters is a linting proxy for "does the important part survive truncation", never a pass/fail gate.
Rewriting is the separate problem, and the one that matters on docs sites. Google may build its own title link from:
- the visible page title
- heading elements
- `og:title`
- prominent styled text
- other page text
- anchor text in links pointing at the page
- `WebSite` structured data
Its stated trigger is having "detected an issue on the page", such as a boilerplate or breadcrumb-shaped title. The remedy is to make the template's shape informative. Trimming characters changes nothing.
The negative example this replaces, because it is the most tempting wrong fix:
| Wrong fix | What actually happened | Right fix |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| a script shortening 400 titles to 60 characters | Google was rewriting the title link because every title read `Docs - Guides - …` - not because titles were long | change the generator's title template so the page's own subject comes first |
| adding the product name to every `h1` to "reinforce the brand" | the `h1` stopped describing the page, giving the rewriter one more unhelpful source | leave the brand in the title template and keep the `h1` about the task |
## Descriptions and snippets
A meta description is not a ranking factor and Google rewrites most of them, but a written one still wins wherever the page opens with boilerplate - which on a generated reference page it always does.
Write descriptions for the pages that matter (quickstart, top how-tos, top reference entries) and let the rest fall back. One sentence, states the task and the outcome:
| Weak | Strong |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| "This page contains documentation for the webhooks functionality of the Acme platform." | "Register a webhook endpoint, verify the signature, and retry failed deliveries with the Acme API." |
Snippet controls worth knowing: `max-snippet:[n]` caps the length, and the `data-nosnippet` attribute keeps a specific block - a deprecation banner, a legal notice, a breadcrumb rendered into the body - out of the snippet.
The strongest snippet lever is not a tag: make the first screen answer the query. A "Acme is a platform that…" preamble on every page produces identical, useless snippets sitewide.
## Headings and anchors
- One descriptive primary heading per page, matching the page's job, not the site's name. A second valid `h1` is not by itself a defect - the question is whether the primary heading is descriptive and the hierarchy unambiguous, so don't spend a fix-list row on it while the page's real problem is a generated title.
- Heading levels descend without skipping; generated reference pages often emit `h1` → `h4`.
- Every heading gets a stable `id`, kept stable across builds - anchor-level results and product deep-links both depend on it. Renaming an anchor silently breaks every link and cached answer pointing at it.
- On long reference pages, the symbol is the heading. `## createClient(options)` beats `## Creating a client`.
## Internal linking
Docs sites fail on internal linking in a shape general SEO advice misses: navigation links exist everywhere, contextual links almost nowhere. The nav gives every page the same undifferentiated link, so it can't signal which page matters.
Work in this order:
1. **Orphans** - pages with no inbound link from any other page. The crawl script reports these. Every one is either linked from somewhere or deleted.
2. **Dead ends** - pages with no onward link. A reference entry that never links to the how-to that uses it ends the session.
3. **Contextual links from high-traffic pages** - the quickstart, the top three how-tos and the index pages are the ones with authority to pass. Link from them, in prose, with descriptive anchor text.
4. **Cross-mode links** - tutorial → reference for the symbols it used, reference → how-to for the task it serves, troubleshooting → the guide where the failure happens.
5. **Breadcrumbs** - real ones, matching the URL path, with `BreadcrumbList` markup.
Anchor text: `retry failed webhook deliveries`, never `here`, `this page`, or `read more`. Generated "Next/Previous" pagination links are not contextual links and don't substitute for them.
## Structured data: what still pays on a docs site
| Type | Status | Verdict |
| -------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------- |
| `HowTo` | rich result removed by Google in August 2023, documentation deleted | don't build |
| `FAQPage` | narrowed to government/health in 2023, stopped appearing entirely on 7 May 2026 | don't build |
| `BreadcrumbList` | still rendered | worth it - docs have a real hierarchy |
| `SoftwareApplication` / `Organization` | product-level | once, on the product pages |
| `TechArticle` / `Article` | no docs-specific rich result | harmless, low value |
Correct titles plus breadcrumbs beat a schema campaign on a docs site. A general SEO checklist that tells you to add HowTo or FAQPage markup is out of date.
## Cannibalization inside your own estate
A blog tutorial and a docs how-to targeting the same task split impressions and neither wins. Check with a site query per target task, then decide which surface owns it:
```
site:example.com "send a webhook"
```
Defensible default: the docs own task and reference queries, the blog owns narrative, benchmark and opinion queries. Make the loser link to the winner rather than deleting it - a blog post that opens with "the current reference is in the docs" keeps its backlinks and stops competing.
scripts/docs-seo-crawl.py›
#!/usr/bin/env python3
"""Collect per-page search signals for a documentation site.
Reads a sitemap (index files are followed one level) or a plain URL list, fetches
each page, and extracts the signals a docs SEO pass acts on: title, meta
description, canonical, robots directives (meta tag and HTTP header), h1, rough
word count and internal link count.
Only the standard library is used, so it runs anywhere Python 3.9+ is available.
Usage:
python3 docs-seo-crawl.py https://docs.example.com/sitemap.xml --out pages.csv --summary
python3 docs-seo-crawl.py --urls urls.txt --out pages.csv --limit 200
python3 docs-seo-crawl.py https://docs.example.com/sitemap.xml --format json --delay 0.5
"""
import argparse
import csv
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
UA = "docs-seo-crawl/1.0 (+documentation SEO audit)"
TAG_RE = re.compile(r"<(script|style|nav|header|footer)\b.*?</\1>", re.S | re.I)
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.S | re.I)
H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S | re.I)
LINK_RE = re.compile(r"""<a\s[^>]*href=["']([^"'#]+)""", re.I)
META_RE = re.compile(r"<meta\s[^>]*>", re.I)
CANON_RE = re.compile(r"""<link\s[^>]*rel=["']canonical["'][^>]*>""", re.I)
ATTR_RE = re.compile(r"""(\w[\w:-]*)\s*=\s*["']([^"']*)["']""")
STRIP_RE = re.compile(r"<[^>]+>")
def fetch(url, timeout):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read(3_000_000).decode(resp.headers.get_content_charset() or "utf-8", "replace")
return resp.status, resp.geturl(), dict(resp.headers), body
def clean(text):
return re.sub(r"\s+", " ", STRIP_RE.sub(" ", text or "")).strip()
def attrs(tag):
return {k.lower(): v for k, v in ATTR_RE.findall(tag)}
def read_sitemap(url, timeout, depth=0):
"""Return [(loc, lastmod)]. Follows a sitemap index one level down."""
try:
_, _, _, body = fetch(url, timeout)
except Exception as exc: # network, TLS, HTTP — all equally fatal for this URL
print(f"sitemap unreachable: {url}: {exc}", file=sys.stderr)
return []
entries = re.findall(r"<(sitemap|url)\b(.*?)</\1>", body, re.S | re.I)
out = []
for kind, chunk in entries:
loc = re.search(r"<loc>\s*(.*?)\s*</loc>", chunk, re.S | re.I)
if not loc:
continue
loc = loc.group(1).strip()
if kind.lower() == "sitemap":
if depth == 0:
out.extend(read_sitemap(loc, timeout, depth + 1))
continue
mod = re.search(r"<lastmod>\s*(.*?)\s*</lastmod>", chunk, re.S | re.I)
out.append((loc, mod.group(1).strip() if mod else ""))
return out
def inspect(url, lastmod, timeout, delay):
row = {"url": url, "sitemap_lastmod": lastmod, "status": "", "final_url": "",
"title": "", "description": "", "canonical": "", "meta_robots": "",
"x_robots_tag": "", "h1": "", "h1_count": 0, "words": 0, "outbound_links": 0,
"inbound_links": 0, "error": ""}
targets = set()
if delay:
time.sleep(delay)
try:
status, final, headers, body = fetch(url, timeout)
except urllib.error.HTTPError as exc:
row["status"] = exc.code
row["error"] = "http error"
return row, targets
except Exception as exc:
row["error"] = str(exc)[:120]
return row, targets
row["status"] = status
row["final_url"] = final
row["x_robots_tag"] = headers.get("X-Robots-Tag", "")
title = TITLE_RE.search(body)
row["title"] = clean(title.group(1)) if title else ""
for tag in META_RE.findall(body):
a = attrs(tag)
name = (a.get("name") or "").lower()
if name == "description":
row["description"] = clean(a.get("content", ""))
elif name in ("robots", "googlebot"):
row["meta_robots"] = (row["meta_robots"] + " " + a.get("content", "")).strip()
canon = CANON_RE.search(body)
if canon:
row["canonical"] = urllib.parse.urljoin(final, attrs(canon.group(0)).get("href", ""))
h1s = H1_RE.findall(body)
row["h1_count"] = len(h1s)
row["h1"] = clean(h1s[0]) if h1s else ""
text = clean(TAG_RE.sub(" ", body))
row["words"] = len(text.split())
host = urllib.parse.urlparse(final).netloc
links = {urllib.parse.urljoin(final, h) for h in LINK_RE.findall(body)}
targets |= {l.rstrip("/") for l in links if urllib.parse.urlparse(l).netloc == host}
targets.discard(final.rstrip("/"))
row["outbound_links"] = len(targets)
return row, targets
def summarize(rows):
titles = Counter(r["title"] for r in rows if r["title"])
dup_titles = {t: n for t, n in titles.items() if n > 1}
flags = {
"pages": len(rows),
"unreachable": sum(1 for r in rows if r["error"] or (r["status"] and r["status"] != 200)),
"noindex": sum(1 for r in rows if "noindex" in (r["meta_robots"] + r["x_robots_tag"]).lower()),
"missing_title": sum(1 for r in rows if not r["title"]),
"duplicate_title_pages": sum(dup_titles.values()),
"distinct_duplicate_titles": len(dup_titles),
"missing_description": sum(1 for r in rows if not r["description"]),
"missing_canonical": sum(1 for r in rows if not r["canonical"]),
"cross_canonical": sum(1 for r in rows if r["canonical"] and r["canonical"].rstrip("/") != r["final_url"].rstrip("/")),
"multiple_h1": sum(1 for r in rows if r["h1_count"] > 1),
"thin_pages_under_100_words": sum(1 for r in rows if r["words"] < 100 and not r["error"]),
"dead_end_pages_no_outbound_links": sum(1 for r in rows if r["outbound_links"] == 0 and not r["error"]),
"orphan_pages_no_inbound_links": sum(1 for r in rows if r["inbound_links"] == 0 and not r["error"]),
}
return flags, sorted(dup_titles.items(), key=lambda kv: -kv[1])[:20]
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("sitemap", nargs="?", help="sitemap or sitemap-index URL")
p.add_argument("--urls", help="file with one URL per line, instead of a sitemap")
p.add_argument("--out", help="write results here (default: stdout)")
p.add_argument("--format", choices=("csv", "json"), default="csv")
p.add_argument("--limit", type=int, default=0, help="stop after N URLs (0 = all)")
p.add_argument("--workers", type=int, default=4)
p.add_argument("--delay", type=float, default=0.2, help="seconds each worker waits before a fetch")
p.add_argument("--timeout", type=float, default=20.0)
p.add_argument("--summary", action="store_true", help="print issue counts to stderr")
args = p.parse_args()
if args.urls:
with open(args.urls) as fh:
targets = [(line.strip(), "") for line in fh if line.strip() and not line.startswith("#")]
elif args.sitemap:
targets = read_sitemap(args.sitemap, args.timeout)
else:
p.error("pass a sitemap URL or --urls")
if args.limit:
targets = targets[: args.limit]
if not targets:
print("no URLs to crawl", file=sys.stderr)
return 1
with ThreadPoolExecutor(max_workers=args.workers) as pool:
results = list(pool.map(lambda t: inspect(t[0], t[1], args.timeout, args.delay), targets))
# Inbound counts are only meaningful once every page has been fetched, so they
# are filled in here rather than inside inspect().
inbound = Counter()
for _, links in results:
inbound.update(links)
rows = []
for row, _ in results:
key = (row["final_url"] or row["url"]).rstrip("/")
row["inbound_links"] = inbound.get(key, 0)
rows.append(row)
stream = open(args.out, "w", newline="") if args.out else sys.stdout
try:
if args.format == "json":
json.dump(rows, stream, indent=1)
else:
writer = csv.DictWriter(stream, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
finally:
if args.out:
stream.close()
if args.summary:
flags, dups = summarize(rows)
print(json.dumps(flags, indent=1), file=sys.stderr)
if dups:
print("most duplicated titles:", file=sys.stderr)
for title, count in dups:
print(f" {count:4d} {title[:90]}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md›
---
name: docs-seo
description: Runs on-page and technical SEO for a documentation site - indexability, canonicals and hreflang across versioned and translated pages, redirects, the generator's title and description templates, internal linking, and an owner-assigned fix list against an agreed pass threshold. Use whenever someone says "docs SEO", "our docs don't rank", "documentation search optimization", "the old version of our docs outranks the current one", "our docs pages aren't indexed", "fix the canonicals on our docs", "docs sitemap problems", "hreflang on our translated docs" or "preview docs got indexed" - even if they never say SEO. Covers versioned, translated and auto-generated reference docs. Not keyword research - use samber/developer-relations-skills@developer-keyword-research.
license: MIT
metadata:
author: Samuel Berthe
version: "1.0.2"
---
# Docs SEO
You are a technical SEO practitioner working on a documentation site. A general SEO audit never looks for these failure modes:
- the same page exists once per release and once per locale
- the title and canonical come from a generator config rather than from a writer
- preview branches leak into the index
- thousands of auto-generated reference pages are simultaneously the thinnest and the highest-intent pages on the domain
You fix what the site publishes: what search engines can crawl, which URL wins among duplicates, what each page's title and snippet say, and how pages link to each other. You do not decide which queries to target, write the content, or chase AI-answer citations. Route elsewhere for:
- Technical search demand: samber/developer-relations-skills@developer-keyword-research
- Page coverage and placement across the docs set: samber/developer-relations-skills@developer-docs-structure-audit
- Error and troubleshooting page content: samber/developer-relations-skills@developer-troubleshooting-docs
- Machine-parseable surfaces for coding agents: samber/developer-relations-skills@coding-agent-docs-optimization
- Assistants citing old-version docs, and measuring AI-answer correctness: samber/developer-relations-skills@developer-docs-ai-visibility
- The tracking plan across all devrel surfaces: samber/developer-relations-skills@devrel-analytics
Typical invocations:
- "audit our docs site for SEO"
- "Google keeps showing our v1 docs instead of v3"
- "half our reference pages aren't indexed"
- "our docs titles all look the same in search results"
- "our staging docs are in Google"
- "we rewrote the docs and traffic dropped"
## Interview
Ask one question at a time, multiple-choice where you can, and skip whatever the user already told you. Questions 1-4 gate the work - without them you will audit pages that don't matter, or prescribe a canonical posture the platform can't ship.
**Memory: Store a few info about the current task or project context. When memory lives in a file, use `devrel-context.md`; if a different memory system is in use, rely on that instead. Separate task info in different sections. Remove finished tasks. Add a date to a task; no date for general project context. Some interview responses may differ between 2 tasks.**
1. What is the docs URL, and does the site also resolve on a platform host (`*.readthedocs.io`, `*.github.io`, `*.pages.dev`) or a second domain?
2. Which generator and hosting platform, and can you edit its config and deploy?
3. Is the documentation versioned? If so, which versions are published, which is current, and which are still supported?
4. Is it translated? Which locales, and are any of them machine-translated without review?
5. Can you reach Search Console (or an equivalent) for this property, and analytics for the docs path?
6. Is there an auto-generated API/CLI reference tree, and roughly how many pages?
7. Is there a blog or marketing site covering overlapping topics on the same or another domain?
8. What triggered this - a traffic drop, a migration, a launch, or routine hygiene?
9. Are there preview, staging or per-branch docs deployments on public hostnames?
10. Who will actually ship the fixes: docs writers, the platform's own settings, or an infrastructure team?
11. What date does the result have to land by?
12. Is this a one-off win before that date, or a posture the docs keep for years?
13. What is the effort ceiling - writer and platform hours available, and how much URL change the team will accept?
Those last three re-rank Step 3, so ask them before you audit anything:
- a hard near-term date promotes latest-only, the only posture that ships in an afternoon
- a compounding mandate promotes stable-canonical
- a ceiling that refuses URL changes deletes per-version indexable outright
- a team that cannot mint a `stable` alias deletes stable-canonical
If the user has no way to change the generator config or deploy (question 2), stop and say so. Nearly every fix in this skill is a template or configuration change. A per-page workaround list is a worse deliverable that someone still has to ship.
## Step 1 - Build the crawl set
Start from the sitemap, because the gap between the sitemap and reality is itself a finding.
```bash
python3 scripts/docs-seo-crawl.py https://docs.example.com/sitemap.xml --out pages.csv --summary
python3 scripts/docs-seo-crawl.py https://docs.example.com/sitemap.xml --limit 300 --delay 0.5 --out sample.csv
python3 scripts/docs-seo-crawl.py --urls urls.txt --format json --out pages.json --summary
```
The script follows a sitemap index one level, fetches each page, and records:
- status, title, meta description, canonical
- robots directives (meta tag and HTTP header)
- the first `h1`, a rough word count
- outbound plus inbound internal link counts
`--summary` prints the issue counts to stderr: unreachable URLs, `noindex` pages, missing and duplicate titles, missing canonicals, cross-canonicals, thin pages, dead ends and orphans.
Keep `--delay` at or above the default on a site you don't own, and prefer `--limit` for a first pass on a large reference tree.
Without a scriptable environment, sample by hand instead: the site's entry points, ten pages per section, and twenty from the reference tree - this skill's own quotas, chosen for coverage per hour, not statistics. Say in the report that the inventory was sampled.
Then check what the script doesn't:
- `robots.txt`
- the sitemap file itself
- one page fetched with JavaScript disabled
Check the sitemap's shape as well as its contents. The sitemaps.org protocol caps one file at 50,000 URLs and 50MB uncompressed, and a sitemap index at 50,000 sitemaps under the same byte ceiling. A generated reference tree crossed with versions and locales is the one docs case that reaches those numbers, and a generator emitting a single flat file will breach them silently rather than split into an index.
## Step 2 - Clear the blocking defects first
Work crawl → index → render → signals, in that order. A title rewrite on a page Google cannot crawl is wasted effort, and this ordering is what makes the fix list defensible when the user asks what to do first.
Check, in order:
1. `robots.txt` blocking paths that also carry a `noindex` - the classic self-defeating combination, since a blocked page's `noindex` is never read.
2. Non-200 URLs listed in the sitemap.
3. `noindex` on pages that should rank (often inherited from a theme default or a front-matter flag copied across a section).
4. Preview, staging and per-branch hosts responding without `X-Robots-Tag: noindex`.
5. Body text, canonical or navigation links that only appear after JavaScript runs.
6. CSS or JS blocked in `robots.txt`.
7. Per-agent crawler rules that block more than the user intended - including a bot-management default the docs team never chose.
Anything in items 1-6 is a blocking defect: it goes at the top of the fix list and overrides any score the rest of the audit produces. [./references/indexability-and-versioning.md](./references/indexability-and-versioning.md) has the full directive table, the sitemap source per generator, and the commands to verify each check.
Item 7 is a policy call, not a defect. Search discovery, user-directed fetch and model training are separate switches with separate costs. A blanket "block the AI bots" rule usually removes the docs from the assistant answers the company wanted to appear in.
The block can live in a CDN or bot-management default nobody on the docs team chose, not just in `robots.txt`. Report what each rule costs using the category table and per-agent verification rules in the reference above. Stop at access: what to publish for assistants and how to earn correct citations belongs to samber/developer-relations-skills@developer-docs-ai-visibility.
## Step 3 - Settle the version and translation posture
This is the step a general SEO audit skips and the one that decides whether the current release or a three-year-old one wins the query. Start by comparing the declared posture (what the canonical tags say) with the actual one (which URL Google indexes) - they frequently disagree.
Which versions exist, their docs state (current, maintained, frozen, archived) and whether each needs its own search entry come from samber/developer-relations-skills@docs-versioning-strategy when it has run; this step turns those intents into one posture.
Three postures exist, ranked here by value per unit of effort rather than by whichever one the generator makes easiest. Reversibility is part of the effort column, because this is the one decision in the skill that changes every URL's indexing status at once and accumulates links you cannot un-accumulate.
| Posture | URL shape and canonical | Effort, including reversal | Value bought |
| --------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| Stable-canonical | `/en/stable/…` and `/en/v2.3/…`; every version canonicals to the `stable` alias | an hour where the platform ships it (Read the Docs exposes `READTHEDOCS_CANONICAL_URL`), a week where the alias has to be built by hand; reversed by changing one canonical target, since no URL moves | current release wins the query and every supported release still has a live URL |
| Latest-only | `/docs/…` live, `/v1/…` archived and `noindex` | near-zero - one directive on the archive, reversed by removing it | current release wins the query outright; nobody on an old release finds their page |
| Per-version indexable | one self-canonical page per version, plus a version switcher | a standing job - every release multiplies the indexable surface, the sitemap and the link mesh; reversal needs a per-page redirect map across every version already indexed | readers on old releases land on the page for their own release |
Each posture is already shipped somewhere, not hypothetical:
- Redocly's own documentation-SEO guidance recommends canonicalizing every version to the current release for most docs sites - the same mechanism as stable-canonical, aimed at "latest" rather than a separate alias.
- Docusaurus added a per-version noindex option after deprecated versions started outranking current docs in search - latest-only in practice.
- Kubernetes keeps its last several releases live on separate hosts, indexed and appearing in search, while its `robots.txt` blocks only its two oldest versions - per-version indexable with a retirement cutoff, not an unbounded archive.
- efficiency: stable-canonical > latest-only > per-version indexable
- value: stable-canonical > per-version indexable > latest-only, whenever older releases are still supported
- effort: per-version indexable > stable-canonical > latest-only
Reversibility gets no line of its own because it orders exactly as effort does here. The posture that costs the most to build is the same one that costs the most to unwind, and a second identical line would only look like a second opinion.
Default to stable-canonical, and say so as a recommendation. Move down to latest-only when only the current release is supported and the deadline is weeks away.
This order starves per-version indexable, which is high on value and highest on effort, so it loses every round. Promote it anyway when supported LTS releases carry real traffic, or when a support commitment obliges you to serve each release's own page.
Delete the postures the user's constraints rule out rather than ranking them last, and name which you deleted. A team that cannot mint or redirect a `stable` alias has no stable-canonical option. A team publishing exactly one release has no posture decision at all - say that and move on.
Re-rank against what you already know: a platform that ships one posture as a feature, a docs team that owns the CDN, or an existing per-version URL set with years of inbound links all change the answer. The ranking is a default, not a law.
Then align the rest of the signals:
1. Make the sitemap, the canonical tags and the internal links all point at the same target. Conflicting signals are why Google picks its own canonical.
2. Give every locale a self-canonical and a complete `hreflang` set. Canonicalizing a translation to English deletes it from search.
3. Pick one primary domain when the site resolves on several, and canonical or redirect the rest at it.
4. For a version being retired, choose redirect targets per page rather than sending the whole tree to the docs home.
Ask the user to confirm the posture before you write any canonical rules.
Google's own localized-versions documentation settles four `hreflang` questions - treat the first three as hard requirements, and report a breach as a defect, not a suggestion:
- Every language version lists itself as well as all the others; include `x-default` for the no-match case.
- Return links are bidirectional: if page X points at page Y and Y does not point back, the annotations may be ignored.
- The value is an ISO 639-1 language, optionally followed by an ISO 3166-1 Alpha 2 region. A region alone is invalid.
- HTML tags, HTTP headers, and sitemap entries are equivalent delivery methods. Google states no preference, large site or not.
Prefer the sitemap anyway, on operational grounds only: a locale × version tree puts hundreds of tags in every `<head>`. Regenerate the whole mesh when a locale launches - adding `/ja/` without regenerating `/fr/`'s alternates leaves `/ja/` with no return link from `/fr/`. The validation checklist and the invalid-code list are in [./references/indexability-and-versioning.md](./references/indexability-and-versioning.md).
## Step 4 - Fix page signals at the template level
On a docs site titles, descriptions and heading structure are generated, so the fix is one template edit that reaches every page. Per-page rewrites come second, and only for the pages that carry traffic.
- Rewrite the title template to put the searched term first and the brand last. This skill's default shape is `{page} | {product} {section}` - adapt it, keep the order.
- Write descriptions for the quickstart, the top how-tos and the highest-traffic reference entries; let the rest fall back rather than generating filler.
- Give each page one descriptive primary heading and levels that descend without skipping. Don't fail a page for having a second valid `h1`; judge whether the hierarchy is unambiguous.
- Keep heading `id` anchors stable across builds, so long reference pages can earn anchor-level results and the product can deep-link into them.
- Skip `HowTo` and `FAQPage` structured data - Google withdrew both rich results (`HowTo` in August 2023, `FAQPage` entirely by May 2026). `BreadcrumbList` still renders and docs have a real hierarchy to express.
Treat character counts as lint, never as a gate. Google states there is no limit on `<title>` length: the title link is truncated to fit the device width, so a 50-60 character rule only checks whether the important part survives.
The failure that actually costs docs sites is rewriting: when Google detects an issue with a title - on docs sites, usually a boilerplate or breadcrumb-shaped one - it may build its own title link from headings, anchor text, `og:title` and other page text. Fix what made the template unhelpful. Shortening it changes nothing.
Worked weak-versus-strong examples, the snippet controls, and the structured-data status table are in [./references/page-signals-and-snippets.md](./references/page-signals-and-snippets.md).
## Step 5 - Repair internal linking
Docs sites have navigation links everywhere and contextual links almost nowhere, so nothing signals which page matters. Fix orphans first (the crawl script lists them), then dead ends, then add contextual prose links from the highest-traffic pages - the quickstart and top how-tos are the ones with authority to pass.
Cross-mode links are where docs gain the most:
- tutorial → reference for the symbols it used
- reference → how-to for the task it serves
- troubleshooting → the guide where the failure happens
Use descriptive anchor text, never "here" or "read more".
Check cannibalization in the same pass. A blog tutorial and a docs how-to targeting one task split impressions and neither wins. Decide which surface owns the query and make the loser link to the winner.
## Step 6 - Page experience
Docs sites are usually fast and then regress through one addition: a client-side search widget, an embedded API explorer, or an unoptimized diagram set. Check Core Web Vitals for the docs path specifically rather than the domain average.
Each metric's own pass mark comes from Google. The share of docs URLs you require to reach it is a target you set with the user (this skill's baseline is 90%).
Where the environment can run a page-speed audit, run it on three page types - the docs home, a long guide, and a reference page - and iterate until each passes. Where it cannot, use the field data in Search Console and say the lab data is missing.
## Step 7 - Ship the fix list and set the threshold
Deliver a prioritized fix list, not a findings dump. The report covers, in order:
- a verdict paragraph
- a coverage statement naming what you could not verify
- the blocking defects
- the version and translation posture
- page signals
- internal linking
- the ranked fix list and the agreed thresholds
Every fix row names what changes, in which file or system, and who owns it. A defect list with no owner column comes back unchanged at the next audit. Order by (pages affected × severity) ÷ effort - this skill's own formula, not a standard - with blocking defects on top regardless of effort.
Say the ordering out loud in the report so the reader knows it is a ratio and not a severity list. That ratio starves the fix that touches few pages and takes a week: server-side rendering for a client-rendered shell, and the enterprise page set in the Audience note below, which no page-count ranking ever surfaces. Promote either by hand when the pages it affects are the ones the business is paid for.
Agree the pass thresholds with the user before the fix pass so remediation has a finish line, and keep the two kinds of number apart - only one of them is negotiable. What a search engine enforces is a defect, not a threshold:
- a page disallowed in `robots.txt` never has its `noindex` or canonical read
- an `hreflang` set without return links may be ignored entirely
- a sitemap file over 50,000 URLs or 50MB uncompressed is out of spec
Those go in the blocking-defects section and are not up for discussion.
The threshold table holds only this skill's own baselines - assembled here, not industry standards:
- ≥95% of canonical docs URLs indexed
- zero pages where Google chose a different canonical
- zero non-200 URLs in the sitemap
- 100% unique titles
- zero orphans
- zero indexed preview or retired-version URLs
- ≥90% of docs URLs passing Core Web Vitals
Say plainly that they are defaults, then adjust them to the site: a 20-page docs site should hit 100% indexation, a 20,000-page reference tree will not.
Report ranking outcomes as pending, because that is what they are: you can verify configuration and consistency in this session, but re-indexing, re-ranking and re-snippeting happen on the search engine's schedule, not yours. An invented ranking-factor weight or a predicted traffic gain is how a technically correct audit loses its credibility.
Re-run the crawl after the fixes land and diff the two summaries. That diff is the proof the work landed. The original findings list is not.
The report template, a worked excerpt, the Search Console segmentation rules and the maintenance triggers are in [./references/audit-report-and-metrics.md](./references/audit-report-and-metrics.md).
## Audience note
The mechanics are identical whether the docs serve developers adopting a tool self-serve or teams evaluating it under a company purchase. What differs is which pages carry the weight. Self-serve adoption lands on quickstarts, how-tos and error pages.
Company evaluation lands on limits, security, compliance, self-hosting and migration pages - usually the thinnest, least-linked, least-indexed pages on the site, because nobody treats them as documentation. When the product has an enterprise motion, check that second set explicitly. A traffic-ranked page list will never surface it.
## Failure modes
| Failure | What it looks like | Fix |
| ------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
| Blocked and noindexed | `robots.txt` disallows a path whose pages carry `noindex` | remove the disallow so the directive can be read, or redirect |
| Canonical roulette | sitemap, canonical tags and nav point at three different versions | one posture, applied to all three signals |
| Canonical to a stranger | old page canonicalized to a current page that says something else | redirect to the nearest equivalent instead |
| Translation erased | `/fr/` canonicalized to `/en/` | self-canonical per locale plus a full `hreflang` set |
| One-way hreflang | `/en/` lists `/ja/`, `/ja/` lists nobody | full mesh with return links, regenerated across every locale |
| Sitemap over the cap | one flat file for a 60,000-page reference tree | split into a sitemap index, 50,000 URLs and 50MB per file |
| Title trimmed to 60 | 400 titles shortened to hit a character rule | fix the template's shape; there is no title length limit |
| Blanket AI-bot block | one `Disallow` for every assistant crawler | decide per agent - search, user fetch and training are separate |
| Build-stamp lastmod | every page's `lastmod` equals the last deploy | derive it from the source file's commit date, or drop it |
| Preview in the index | PR or staging hosts serving 200 with no robots header | `X-Robots-Tag: noindex` at the CDN, verified with `curl -I` |
| Per-page whack-a-mole | 400 hand-written titles instead of one template change | fix the generator config first, pages second |
| Preamble snippets | every page opens with "Acme is a platform that…" | answer the query in the first screen |
| Deprecated schema | a HowTo/FAQPage markup project | breadcrumbs and titles instead |
| Reference blanket-noindex | the whole generated tree excluded to "avoid thin content" | keep it indexable; split per resource, not per field |
| Redirect to home | a retired version's URLs all point at the docs index | per-page targets; the index only as last resort |
| Score with no threshold | "docs SEO health: 78/100" | agreed numeric targets set before the fix pass |
| Estimated query data | third-party volume numbers for technical queries | Search Console, or an explicit "not verified" |
## References
- samber/developer-platform-skills@api-reference-quality for the content quality of an API reference tree once this skill has made it indexable
- samber/developer-relations-skills@docs-localization-strategy for whether a locale should exist at all, which pages it translates, and when a stale translation is retired
- samber/developer-relations-skills@docs-versioning-strategy for which versions keep docs, their state, the switcher and the retirement trigger this skill's posture implements