---
title: SEO
description: Metadata, Open Graph images, RSS feeds, and JSON-LD — Blume's discoverability layer, grouped under one seo config.
sidebar:
  label: SEO
---

Blume handles the discoverability layer for you: page metadata, social share images, feeds, and structured data. The configurable features live under the `seo` key in `blume.config.ts`; metadata is driven by your content.

```ts blume.config.ts lineNumbers
seo: {
  og: { enabled: true },
  rss: { enabled: true, types: ["blog", "changelog"] },
  sitemap: true,
  robots: true,
  structuredData: true,
  x: { handle: "@acme" },
}
```

Most of this is sharper with an absolute site URL — set [`deployment.site`](/docs/deployment) so feeds, OG images, canonicals, the sitemap, and JSON-LD can emit full URLs.

## Metadata

Every page renders the standard `<head>` tags from your config and frontmatter:

- `<title>` — the page title plus your site `title`.
- `<meta name="description">` and `og:description` — the page `description`, falling back to the site `description`.
- `og:title` and `og:site_name` — the page title and your site `title`.
- `<link rel="canonical">` and `og:url` — the page's absolute URL (when `deployment.site` is set).
- `og:type` — `article` on blog posts and changelog entries, `website` elsewhere. Article pages also emit `article:published_time` and `article:modified_time` from the page's `date` and last-modified timestamp.
- `og:image` — the [OG image](#open-graph-images) for the page. A generated card also declares its `og:image:width`, `og:image:height`, `og:image:type`, and `og:image:alt`, so a crawler can lay the card out without fetching it first; an `seo.image` you supply yourself declares none of these, since its size and format are unknown.
- `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` — the X card. Pages with an image get the wide `summary_large_image` variant; pages without one still get the compact `summary` card rather than rendering as a bare link.

### X attribution

X reads everything else on the card from the `og:*` tags, so the only values it can't infer are the accounts to credit. Set them under `seo.x` and Blume emits `twitter:site` (your site's account) and `twitter:creator` (the author's). The `@` is optional — `acme` and `@acme` both work.

```ts blume.config.ts lineNumbers
seo: {
  x: { handle: "@acme", creator: "@jane" },
}
```

A page can claim its own author, which is what you want for a guest post:

```yaml lineNumbers
---
title: How we shipped it
seo:
  x:
    creator: "@guestauthor"
---
```

Override any of the other tags per page with `seo` frontmatter:

```yaml lineNumbers
---
title: Pricing
description: Plans and pricing for every team size.
seo:
  title: Pricing — Acme
  canonical: https://acme.com/pricing
  noindex: false
---
```

<TypeTable
  type={{
    "seo.title": {
      type: "string",
      description: "Override the <title> and og:title for this page.",
    },
    "seo.description": {
      type: "string",
      description: "Override the meta + og:description.",
    },
    "seo.image": {
      type: "string",
      description: "Custom social image (see Open Graph).",
    },
    "seo.canonical": {
      type: "string",
      description: "Override the canonical URL.",
    },
    "seo.noindex": {
      type: "boolean",
      description: "Emit robots noindex and skip structured data.",
    },
    "seo.x.creator": {
      type: "string",
      description:
        "Credit this page to an X account (twitter:creator), overriding seo.x.creator from your config.",
    },
  }}
/>

## Open Graph images

Blume can render a 1200×630 social card for every page at build time — no headless browser, thanks to [Takumi](https://takumi.kane.tw), so builds stay fast. On by default once [`deployment.site`](/docs/deployment) is set or auto-detected (the `og:image` URL has to be absolute to be useful to crawlers), and off otherwise. Set `enabled` to override that either way:

```ts blume.config.ts lineNumbers
seo: {
  og: { enabled: true }, // or false to opt out even with a site set
}
```

### Brand the generated card

Set a local SVG and color palette to match the generated card to your brand. The logo can live in `public/` or at the project root. Omit any palette value to keep its default.

```ts blume.config.ts lineNumbers
seo: {
  og: {
    logo: "/logo/og.svg",
    palette: {
      accent: "#ff5410",
      background: "#1d1d1d",
      foreground: "#fff6f2",
      muted: "#a6a19f",
      border: "#323232",
    },
  },
}
```

By default, each card is derived from your content and theme — the **page title** as the headline, your **site title** as the eyebrow, and your theme **accent** for the mark. Images are served at `/og/<slug>.png`, mirroring each route, and are prerendered as static files even in server mode:

| Page route          | Image URL                  |
| ------------------- | -------------------------- |
| `/`                 | `/og/index.png`            |
| `/quickstart`       | `/og/quickstart.png`       |
| `/configuration/ai` | `/og/configuration/ai.png` |

Override the generated card for any page with `seo.image` — a file in `public/` or an external URL. It takes precedence over the generated card and works even when `og` is off, so you can mix custom images with generated ones:

```yaml lineNumbers
---
title: Pricing
seo:
  image: /og/pricing-custom.png
---
```

:::note
Every palette color accepts any CSS color — hex, `oklch(…)`, `rgb(…)`, and so on. The accent also accepts a named preset (`blue`, `teal`, …), matching [`theme.accent`](/docs/configuration/theming#accent). A color the renderer can't parse fails the build rather than silently shipping a default-colored card.
:::

Emoji in a page title or site title render as [Twemoji](https://github.com/jdecked/twemoji) glyphs, fetched from a CDN while the card renders — so a build whose titles contain emoji needs network access. Each glyph is fetched once per build, however many pages use it.

### Show, hide, or override card layers

Beyond the headline, the card carries three optional layers: the **brand mark** in the top-left (your logo, or an accent tile with the site title's initial), the **subtitle** under the headline (your site `description`), and a **footer** with your repo slug (from `github`) and the site's URL — the deployment site's host plus [`deployment.base`](/docs/deployment#subpath-deploys), so a GitHub Pages project site reads `user.github.io/repo`. Override any of them with a string of your own, or hide one with `false`:

```ts blume.config.ts lineNumbers
seo: {
  og: {
    site: "docs.acme.com", // footer URL text, or false to hide it
    description: false, // hide the subtitle; a string overrides it
    logo: false, // no brand mark at all — not even the initial tile
  },
}
```

### Card fonts

By default the card renders in Takumi's built-in font, which covers only Latin glyphs — a title in another script (Japanese, Chinese, Korean, Arabic, …) would render as tofu, empty boxes.

**Set [`theme.fonts`](/docs/configuration/theming#fonts) and the card follows it.** When your config picks its own fonts, the generated cards automatically render the headline in your display font and the description and footer in your body font, so shared links match the site — including non-Latin coverage, with nothing to configure here. (Families from non-Google providers are skipped — the card renderer can only fetch from Google Fonts — but local font files work.)

To use different fonts on cards than on the site, or to add script coverage without touching the theme, set `og.fonts` explicitly — it always wins over the theme-derived fonts:

```ts blume.config.ts lineNumbers
seo: {
  og: {
    fonts: [
      "Noto Sans JP",
      { name: "Inter", weight: [400, 700] },
      { name: "Berkeley Mono", src: "./fonts/BerkeleyMono-Regular.woff2" },
    ],
  },
}
```

Each entry is a Google Fonts family name, an object pinning its `weight` (a number, a list, or a variable range like `"100..900"`) and `style` (`"normal"`, `"italic"`, or both), or a local font file — `src` resolves from the project root, with optional `weight` and `style` when the file's own metadata shouldn't decide.

Google families are fetched at build — so a build that uses them needs network access — and the renderer only pulls the glyph subsets each title actually uses. Fallback is per-glyph, so adding a family only affects glyphs the other fonts can't draw.

An explicit `og.fonts: []` opts out entirely: cards keep the built-in font even when `theme.fonts` is set.

### Custom page titles

A custom [`.astro` page](/docs/advanced/custom-pages) has no frontmatter to read, so its generated card is titled by humanizing the last URL segment of its route — `/getting-started` becomes "Getting Started", but `/cli` becomes "Cli". Name those cards explicitly with `og.titles`, keyed by route (`"/"` addresses the home, whose card otherwise carries the site title):

```ts blume.config.ts lineNumbers
seo: {
  og: {
    titles: {
      "/cli": "CLI",
    },
  },
}
```

Entries only apply to custom pages — a content page's card always takes its headline from the page title, so retitle those in frontmatter instead.

`seo.image` is frontmatter, so it only covers Markdown and MDX content. To give a custom [`.astro` page](/docs/advanced/custom-pages) its own social image — a marketing home or landing page, and the way to give the home page alone a bespoke share image — pass the `ogImage` prop to `PageLayout`.

## RSS feeds

Blume builds an RSS feed for each content type in `rss.types` — `blog` and `changelog` by default — that has pages, served at `/<type>/rss.xml`. See [Feeds](/docs/content#feeds) for authoring blog and changelog entries with dates.

```ts blume.config.ts lineNumbers
seo: {
  rss: {
    enabled: true,
    types: ["blog", "changelog"],
    limit: 50,
  },
}
```

| Option    | Default                 | Description                           |
| --------- | ----------------------- | ------------------------------------- |
| `enabled` | `true`                  | Generate feeds.                       |
| `types`   | `["blog", "changelog"]` | Content types that each get a feed.   |
| `limit`   | `50`                    | Maximum items per feed, newest first. |

Blume injects `<link rel="alternate">` tags so browsers and feed readers discover the feeds automatically.

## Structured data

Blume emits [schema.org](https://schema.org) JSON-LD in every page's `<head>` so search engines understand your content. On by default:

```ts blume.config.ts lineNumbers
seo: {
  structuredData: true,
}
```

Each page includes:

- a **WebSite** node for site identity,
- the page as an **article** — `BlogPosting` for blog posts, `TechArticle` for changelog and docs — with its description and publish date,
- a **BreadcrumbList** built from the navigation trail.

URLs are absolute when `deployment.site` is set. Pages marked `seo.noindex` are skipped.

## Sitemap

Blume writes a `sitemap.xml` of every indexable page at build time. It needs an absolute [`deployment.site`](/docs/deployment) and lists every page except drafts, hidden, and `noindex` pages. On a [versioned](/docs/content/versioning) site, archived pages whose canonical points at their live equivalent are left out too — the live page is the one to index. On by default:

```ts blume.config.ts lineNumbers
seo: {
  sitemap: true,
}
```

Ship your own `public/sitemap.xml` to take over — Blume never overwrites a file you place in `public/`.

## Robots

Blume writes a `robots.txt` that allows all crawlers, declares your [content signals](#content-signals), and adds a `Sitemap:` line pointing to the sitemap when one is available. On by default:

```ts blume.config.ts lineNumbers
seo: {
  robots: true,
}
```

```txt robots.txt
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=yes
Allow: /

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

### Content signals

The `Content-Signal` line — the emerging content-usage convention — declares how AI crawlers may reuse your docs. Blume emits it **on by default with every signal set to `yes`**, matching its stance that docs are open to humans and agents alike:

- `search` — traditional and AI search indexing
- `aiInput` — grounding / RAG at answer time
- `aiTrain` — model training

Restrict any signal by setting it to `false`; the ones you leave out stay `yes`:

```ts blume.config.ts lineNumbers
seo: {
  contentSignals: {
    aiTrain: false, // opt out of training, keep search + grounding
  },
}
```

```txt robots.txt
User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Allow: /
```

Set `contentSignals: false` to drop the declaration entirely:

```ts blume.config.ts lineNumbers
seo: {
  contentSignals: false,
}
```

<TypeTable
  type={{
    "seo.contentSignals": {
      type: "boolean | object",
      description:
        "Content-Signal declaration. true or omitted emits all signals as yes; false drops the line; an object sets signals individually.",
    },
    "contentSignals.search": {
      type: "boolean",
      description: "Allow use for search indexing (search). Default true.",
    },
    "contentSignals.aiInput": {
      type: "boolean",
      description:
        "Allow use for AI grounding / RAG at answer time (ai-input). Default true.",
    },
    "contentSignals.aiTrain": {
      type: "boolean",
      description: "Allow use for AI model training (ai-train). Default true.",
    },
  }}
/>

Content signals express a preference, not access control: they tell well-behaved crawlers how you'd like your content used, and it's on the crawler to honor them.

Ship your own `public/robots.txt` to take over.
