---
title: SEO
description: One resolved object on every page-shaped thing, with the fallback chain already applied.
---

Anything in ZevContent that corresponds to a **page** carries a complete `seo`
object: blog articles, blog categories, knowledge base articles, knowledge base
collections, and authors.

```json
"seo": {
  "title": "How we deploy",
  "description": "A short tour of the pipeline.",
  "og_image": {
    "url": "https://cdn.example.com/media/deploy.jpg",
    "alt": "A deployment pipeline",
    "width": 1200,
    "height": 630,
    "content_type": "image/jpeg"
  },
  "canonical_url": null,
  "noindex": false
}
```

It always arrives **complete and resolved**. Render what you are given.

## The fallback chains

A writer fills in as much as they care to. The server applies the rest, in the
same code path for every module:

| | `title` falls back to | `description` falls back to | `og_image` falls back to |
|---|---|---|---|
| **Blog article** | the article title | the excerpt, then the opening of the body | the featured image |
| **Blog category** | the name | the description | the featured image |
| **KB article** | the title | the summary, then the opening of the body | the featured image |
| **KB collection** | the title | the description | the collection image |
| **Author** | the name | the opening of the bio | the avatar |

Descriptions derived from a body are cut on a **word boundary**, not mid-word.

`canonical_url` and `noindex` always pass through exactly as set. There is
nothing sensible to derive them from, and guessing a canonical URL is how
duplicate-content problems get created rather than solved.

> **WARNING: Do not reimplement the chain**
>
> The commonest mistake is writing `article.seo.title || article.title` in a
> frontend. It is redundant on the day you write it, and it becomes wrong the day
> the chain changes: your version and ours will disagree, and only one of them
> is in the sitemap.
>
> `seo.title` is never null. `description` and `og_image` are null only when
> every source in the chain is empty, which means there is genuinely nothing to
> say.

## Rendering it

```html
<title>{seo.title}</title>
{seo.description && <meta name="description" content={seo.description} />}
{seo.canonical_url && <link rel="canonical" href={seo.canonical_url} />}
{seo.noindex && <meta name="robots" content="noindex" />}

<meta property="og:title" content={seo.title} />
{seo.description && <meta property="og:description" content={seo.description} />}
{seo.og_image && <meta property="og:image" content={seo.og_image.url} />}
{seo.og_image?.width && <meta property="og:image:width" content={seo.og_image.width} />}
{seo.og_image?.height && <meta property="og:image:height" content={seo.og_image.height} />}
```

`og_image` is the same media object as everywhere else, so on a **private**
project it is a presigned URL that expires. Social crawlers fetch when someone
shares a link, which may be months after your build. See
[Media](/concepts/media) for why a static build should ask for
`media_urls=permanent`.

## Where `seo` is *not*

Nested references do not carry it. An author inside an article's `authors`
array is a **byline** carrying id, name, slug, bio and social, and a category
inside `categories` is a **breadcrumb**. Neither has `seo` or `article_count`,
because a card printing twenty bylines will never render twenty og images.

Fetch the author's or category's own endpoint for the page shape.

## Custom content types

`seo` is a feature of the built-in modules, not of custom types. Your own type
gets the fields you define. If a `case-study` needs a meta description, add a
`string` field for it and render it yourself.

We do not synthesise an SEO object for a shape we did not design, because we
would have to guess which of your fields is the title.