# SEO and sitemap

> For the complete documentation index, see [llms.txt](https://preview.writedocs.io/llms.txt).

The site's domain, sitemap.xml, robots.txt, canonical URLs, structured data, and the title and meta tags search engines and social media read - for the whole site, and page by page.

## `domain`

The address the site is published at:

```json
{ "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](#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:

| Tag | Value |
| --- | --- |
| `<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:url` | The same title and description, `website`, the site's name, and the canonical URL. |
| `twitter:card`, `twitter:title`, `twitter:description` | The same, as a large card. |
| `og:image`, `twitter:image` | A picture generated for the page - see [Link preview images](#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:

```json
{
  "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](#link-preview-images).

**`generatedOgImage`** `boolean` default: `true`

Whether a page with no `ogImage` gets a [generated link preview image](#link-preview-images). Set it to `false` to build none - see [Turning them off](#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](#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](#leaving-pages-out-of-search-engines).

**`metatags`** `object`

Any other meta tag, as name and content - see [Other meta tags](#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`:

```mdx
---
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:

```mdx
---
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](#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](https://preview.writedocs.io/docs/openapi/multi-spec-and-overrides/#your-own-page-for-an-endpoint).

## Link preview images

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:

```json
{
  "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](#leaving-pages-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:

```mdx
---
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.

```json
{
  "seo": {
    "metatags": {
      "google-site-verification": "your-verification-code",
      "twitter:site": "@acme",
      "og:locale": "en_GB"
    }
  }
}
```

```html
<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

```mdx
---
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](https://preview.writedocs.io/docs/configuration/mcp/).

It's still in the site's own [search](https://preview.writedocs.io/docs/search/).

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

```mdx
---
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`](#robotstxt) 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:

| On | What it says |
| --- | --- |
| Every page | What the page is - a `TechArticle`, or an `APIReference` for an [API page](https://preview.writedocs.io/docs/openapi/overview/) - with its title, description, address and language. A page with [`mode`](https://preview.writedocs.io/docs/content/page-modes/) `frame`, `custom` or `blank` is a plain `WebPage`. |
| Every page inside a group | Its `BreadcrumbList`: the home page, each group above it that has a page of its own, and the page. |
| The home page | The site, as a `WebSite` - and who publishes it, when you set `seo.organization`. |

A page that's [left out of search engines](#leaving-pages-out-of-search-engines) has none.

### Your organization

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

```json
{
  "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](https://preview.writedocs.io/docs/configuration/logo-and-favicon/).

**`sameAs`** `string[]`

Its profiles on other sites. Without it, the addresses in [`socials`](https://preview.writedocs.io/docs/configuration/topbar-and-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](https://schema.org) type, written as YAML. It's added after the ones above:

```mdx
---
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](https://search.google.com/test/rich-results) after publishing.

## Languages

On a site with [languages](https://preview.writedocs.io/docs/configuration/navigation-advanced/), a page that exists in several of them tells search engines so, and they show each reader the one in their language:

```html
<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](#leaving-pages-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:

```text
User-agent: *
Allow: /

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

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

```text
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:

```text title="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.