WritedocsWritedocs

SEO and sitemap

domain

The address the site is published at:

{ "domain": "docs.example.com" }

docs.example.com and https://docs.example.com both work. With domain set, the site gets:

  • /sitemap.xml, listing every page search engines should index - see Sitemap.
  • A canonical URL on every page - <link rel="canonical" href="https://docs.example.com/docs/quickstart/"> - so search engines know a page’s main address.
  • Full URLs in the og:url, og:image and twitter:image tags, which social media need for link previews.
  • Full URLs in llms.txt, and in the links the Copy page menu hands to AI assistants.

Without domain, the site works the same, without a sitemap and with relative URLs in those places. domain doesn’t set up hosting or DNS - it tells Writedocs where the site will be.

What every page gets

With nothing set, each page has:

TagValue
<title>The page’s title, then the site’s name: Quickstart · Acme Docs. A page whose title is the site’s name shows it once: Acme Docs.
<meta name="description">The page’s description, or the site’s description from writedocs.json.
<link rel="canonical">The page’s address on domain.
og:title, og:description, og:type, og:site_name, og:urlThe same title and description, website, the site’s name, and the canonical URL.
twitter:card, twitter:title, twitter:descriptionThe same, as a large card.
og:image, twitter:imageA picture generated for the page - see Link preview images.

seo, below, changes any of these and adds to them - for the whole site in writedocs.json, or for one page in its frontmatter.

seo

Defaults for every page’s meta tags:

{
  "seo": {
    "ogImage": "/images/social-card.png",
    "ogType": "website",
    "twitterCard": "summary_large_image",
    "keywords": ["payments", "api", "developer docs"],
    "metatags": {
      "google-site-verification": "your-verification-code",
      "twitter:site": "@acme"
    }
  }
}
ogImage string

The image in link previews on social media and chat apps - the og:image and twitter:image tags. A path in your project, or a full URL. 1200×630 pixels works well everywhere. Without it, each page gets a generated one.

generatedOgImage boolean default: true

Whether a page with no ogImage gets a generated link preview image. Set it to false to build none - see Turning them off. Only in writedocs.json, not in a page’s frontmatter.

ogType string default: website

The page’s og:type, like website or article.

twitterCard "summary" | "summary_large_image"

The kind of preview on X (Twitter). Without it, summary_large_image when there’s an image, and summary otherwise.

keywords string[]

The page’s <meta name="keywords">.

noindex boolean default: false

Asks search engines not to list the page - see Leaving pages out of search engines.

robots string

The whole <meta name="robots"> value, like "noindex, follow" - for what noindex doesn’t say. See Leaving pages out of search engines.

metatags object

Any other meta tag, as name and content - see Other meta tags.

Page by page

A page’s frontmatter can set its own seo. Each field it sets replaces the site’s default; the rest still come from writedocs.json:

---
title: Enterprise SSO
description: Set up single sign-on for your organization.
seo:
  ogImage: /images/social/sso.png
  keywords:
    - sso
    - saml
---

Three more fields exist only in a page’s frontmatter, since they’re about one page:

---
title: Webhooks
description: Get a call from Acme when something happens.
seo:
  title: "Acme webhooks: events, signatures and retries"
  description: How Acme sends webhooks, how to verify their signature, and what happens when your endpoint is down.
  canonical: /docs/guides/webhooks/
---
title string

The page’s <title> - what the browser tab and search results show - and its og:title and twitter:title. It’s used exactly as written: the site’s name isn’t added, so include it if you want it. The heading on the page and the sidebar keep title.

description string

The page’s <meta name="description">, og:description and twitter:description, when search engines should get a different text from the description shown under the page’s title.

canonical string

The page’s canonical URL, when it isn’t the page’s own address - see Canonical URLs.

API pages

A page generated from an OpenAPI spec takes its meta description from the endpoint’s description in the spec, as plain text cut to about 160 characters. An endpoint with no description gets its summary with the request: List all customers - GET /customers. To write one yourself, give the endpoint a page of its own.

When someone shares a link to a page - on Slack, LinkedIn, X, in a chat - the preview shows a picture. Every page gets one, generated when the site is built, with nothing to set up:

  • your logo (the site’s name, when there’s no logo);
  • the page’s title and description;
  • a band in your primary color.

It’s a 1200×630 PNG at /og/ plus the page’s address: /og/docs/quickstart.png, and /og/index.png for the home page. In writedocs dev, open that address to see a page’s picture - it’s drawn again on each request, so a reload shows a change to the title, the description or the logo.

To use a picture of your own:

  • For every page - set seo.ogImage in writedocs.json. No pictures are generated.
  • For one page - set seo.ogImage in its frontmatter. The other pages keep their generated ones.

Turning them off

Drawing a picture takes about 25 milliseconds a page when the site is built: nothing you’d notice on a few hundred pages, close to a minute on 2,000. To build none:

{
  "seo": {
    "generatedOgImage": false
  }
}

With it off:

  • no /og/ picture is built, for any page;
  • a page with no seo.ogImage of its own has no og:image or twitter:image tag, and its preview on X is the small summary kind - a shared link shows the title and description without a picture;
  • a page that sets seo.ogImage in its frontmatter keeps it.

Pictures are generated unless the value is exactly false. It’s a setting for the whole site: to leave one page without a generated picture, give that page its own seo.ogImage.

Limits:

  • The picture is always on a white background, in the Inter font - it doesn’t follow the site’s fonts or dark colors.
  • Letters outside Latin alphabets (Chinese, Japanese, Arabic, emoji) aren’t drawn. For a site in one of those languages, set seo.ogImage.
  • A logo that’s a URL on another site isn’t fetched; the picture shows the site’s name. So does a logo whose file isn’t a PNG, JPEG, GIF, WebP or SVG.
  • A page left out of search engines has none.
  • Social networks keep a picture for days after first reading it. A page’s new title can take that long to show in previews.

Canonical URLs

Every page’s canonical URL is its own address on domain. When the same content is at two addresses, point one at the other with seo.canonical, so search engines list only the main one:

---
title: Webhooks (v1)
seo:
  canonical: /docs/guides/webhooks/
---

A path is on the site’s own domain. A full URL works for content whose main copy is on another site: canonical: https://blog.example.com/webhooks.

A page whose canonical is another address is left out of sitemap.xml. Its og:url is the canonical too.

A path needs domain to become a full URL - without domain, the page has no canonical link.

Other meta tags

seo.metatags adds any meta tag - in writedocs.json for every page, or in a page’s frontmatter for that page. A page’s tags are added to the site’s; one with the same name replaces it.

{
  "seo": {
    "metatags": {
      "google-site-verification": "your-verification-code",
      "twitter:site": "@acme",
      "og:locale": "en_GB"
    }
  }
}
<meta name="google-site-verification" content="your-verification-code">
<meta name="twitter:site" content="@acme">
<meta property="og:locale" content="en_GB">
  • A tag Writedocs writes itself is replaced by one with the same name. "twitter:image": "/images/card-x.png" gives X its own image, while og:image keeps seo.ogImage.
  • Open Graph tags (og:, article:, fb: and the like) are written with property, the others with name - the form each kind is read in.
  • An image’s path becomes a full URL on domain, in og:image, twitter:image and any other name ending in image.

Verifying the site with a search engine

To verify the site in Google Search Console, choose the HTML tag method and copy the tag’s content into metatags, as above. Bing’s tag is msvalidate.01.

Verifying through a DNS record works too, and needs nothing in the project.

Leaving pages out of search engines

---
title: Migration notes
seo:
  noindex: true
---

A page with seo.noindex is still published, and works for anyone with its address. It:

  • has <meta name="robots" content="noindex, nofollow">, so search engines don’t list it or follow its links;
  • is left out of sitemap.xml, llms.txt, llms-full.txt and the MCP server.

It’s still in the site’s own search.

For anything else, seo.robots sets the tag’s whole value:

---
title: Changelog archive
seo:
  robots: "noindex, follow"
---

robots replaces noindex when a page has both. A value with noindex (or none) leaves the page out of sitemap.xml and the rest, as above; any other value - "max-snippet:50", "noimageindex" - only writes the tag.

A page with hidden: true in its frontmatter is noindex too, unless it sets seo.noindex: false or its own seo.robots.

The whole site

Setting noindex (or a robots with noindex) in writedocs.json’s seo applies it to every page, and makes robots.txt ask crawlers to stay out - for a staging site, for example. A page can still set its own.

Structured data

Structured data tells search engines what a page is, in a form they read directly - it’s what lets a result show a breadcrumb trail in place of a bare address. With domain set, every page has it in its HTML, with nothing to set up:

OnWhat it says
Every pageWhat the page is - a TechArticle, or an APIReference for an API page - with its title, description, address and language. A page with mode frame, custom or blank is a plain WebPage.
Every page inside a groupIts BreadcrumbList: the home page, each group above it that has a page of its own, and the page.
The home pageThe site, as a WebSite - and who publishes it, when you set seo.organization.

A page that’s left out of search engines has none.

Your organization

seo.organization, in writedocs.json, says who publishes the site:

{
  "seo": {
    "organization": {
      "name": "Acme",
      "url": "https://acme.com",
      "logo": "/images/acme-logo.png",
      "sameAs": ["https://github.com/acme", "https://www.linkedin.com/company/acme"]
    }
  }
}
name string required

The organization’s name - “Acme”, not “Acme Docs”.

url string

The organization’s own site. Without it, this site’s address.

logo string

Its logo: a path in your project, or a full URL. Without it, the site’s logo.

sameAs string[]

Its profiles on other sites. Without it, the addresses in socials.

The home page then has an Organization, and every page names it as its publisher.

A page’s own

seo.jsonLd, in a page’s frontmatter, adds a block of your own - any schema.org type, written as YAML. It’s added after the ones above:

---
title: Webhooks FAQ
seo:
  jsonLd:
    "@type": FAQPage
    mainEntity:
      - "@type": Question
        name: Does Acme send a webhook more than once?
        acceptedAnswer:
          "@type": Answer
          text: Yes - until your endpoint answers with a 2xx status, for up to 24 hours.
---

A list of blocks works too. "@context": "https://schema.org" is added to a block that doesn’t have it. Names starting with @ need quotes in YAML.

Writedocs writes the block as you wrote it and doesn’t check it against schema.org - test a page with Google’s Rich Results Test after publishing.

Languages

On a site with languages, a page that exists in several of them tells search engines so, and they show each reader the one in their language:

<link rel="alternate" hreflang="en" href="https://docs.example.com/en/quickstart/">
<link rel="alternate" hreflang="pt-BR" href="https://docs.example.com/pt/quickstart/">
<link rel="alternate" hreflang="x-default" href="https://docs.example.com/en/quickstart/">

There’s nothing to set: the links come from the navigation, and need domain. hreflang is each language’s language code, and x-default - for a reader whose language isn’t one of them - is the first language in the list.

Two pages count as the same page when they’re at the same place in their languages:

  • The languages are built the same way - the same tabs, versions or products in the same order - and
  • the two sidebars list the same number of pages. The page pairs with the one at the same position.

This is stricter than the language switcher, which always takes the reader somewhere. When a language is missing a page, or has an extra one, the pages of that sidebar get no hreflang links for it: a link saying two different pages are the same would do more harm than no link. Keep the languages’ sidebars in step, page for page, and every page is linked.

A page left out of search engines has no hreflang links.

Sitemap

With domain set, every build writes /sitemap.xml again, listing the address of every page except:

  • pages with seo.noindex, or a seo.robots with noindex;
  • pages whose seo.canonical is another address;
  • redirects.

A site with more than 50,000 pages gets /sitemap-index.xml instead, pointing at several sitemap files.

Each page’s entry has a <lastmod> with the date its file last changed, taken from your project’s git history - search engines use it to decide what to read again. A page generated from an OpenAPI spec gets the date the spec last changed. A page has no <lastmod> when git has no date for it: the file was never committed, the project isn’t a git repository, or the site was built from a copy with only the latest commit.

To write the sitemap yourself, put a sitemap.xml at the project root. It’s published as it is, and the generated one is written to /sitemap-index.xml.

robots.txt

Every build writes /robots.txt. It lets every crawler read the whole site, and names the sitemap:

User-agent: *
Allow: /

Sitemap: https://docs.example.com/sitemap.xml

The Sitemap line needs domain. When the whole site is noindex (see above), the file asks crawlers to stay out instead:

User-agent: *
Disallow: /

To write it yourself - to keep AI crawlers out, say - put a robots.txt at the project root. It’s published as it is, in place of the generated one:

robots.txt
User-agent: GPTBot
Disallow: /

User-agent: *
Allow: /

Sitemap: https://docs.example.com/sitemap.xml

writedocs dev serves your own robots.txt; the generated one is written only when the site is built.