Concepts
SEO
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.
"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.
Rendering it
<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} />}
The share version
og_image is not the original file. It is a copy made for link previews:
1200 by 630, cropped to the centre, JPEG, under 300 KB, with its exact
width and height. A full-size PNG exported from a design tool can run to
several megabytes, and at that size WhatsApp shows no preview at all and
LinkedIn gives up fetching. The share version is what those crawlers want,
so put it straight into og:image and twitter:image.
The original is untouched. featured_image, image and avatar still point
at the full-resolution file, and its URL never changes, so anything you have
already published in RSS or JSON-LD keeps working. Use the original for the
hero on the page and og_image for the card.
The copy is made a few seconds after an upload. Until it exists, og_image
serves the original, so a page never loses its picture. Images uploaded before
this existed get theirs the first time a page using them is read, and a
background pass covers the rest.
Every image object also carries the same copy under an optional og key,
so a custom content type’s image can be shared the same way:
"image": {
"url": "https://cdn.example.com/media/hero.png",
"width": 2400,
"height": 1350,
"content_type": "image/png",
"og": {
"url": "https://cdn.example.com/media/og-1200x630.jpg",
"width": 1200,
"height": 630,
"content_type": "image/jpeg"
}
}
og_image follows the project’s access setting like every other image, 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 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.
Updated at, Sunday, September 20, 2026