> ## Documentation Index
> Fetch the complete documentation index at: https://cactal.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Configure SEO metadata

> Configure site and page metadata, build SEO titles and descriptions from CMS content, add social previews and JSON-LD, and control canonicals and crawler behavior.

Cactal renders source-backed SEO metadata into every website page. Set static defaults once in `website_config`, override them per page, or use metadata templates to build values from resolved CMS content, route parameters, and query parameters.

<Note>
  The favicon is not page metadata — it is a current website setting, not part of `website_config` or a page's `metadata`. See [Site favicon](/docs/guides/customize-site-wide-files#site-favicon).
</Note>

## Set site defaults

The `website_config` singleton holds metadata shared by the whole site. Its default export must call `defineWebsiteConfig` directly with an inline object.

```tsx website_config theme={null}
import { defineWebsiteConfig } from '@website'

export default defineWebsiteConfig({
	metadata: {
		title: 'Acme Journal',
		titleTemplate: '%s | Acme Journal',
		description: 'Field notes from the Acme team.',
		language: 'en',
		robots: { index: true, follow: true },
		openGraph: {
			siteName: 'Acme Journal',
			type: 'website',
			images: [
				{
					url: '/social/default.png',
					alt: 'Acme Journal'
				}
			]
		}
	}
})
```

`titleTemplate` applies when a page supplies a title. Every `%s` is replaced with the page title, so `Introducing Cactal` becomes `Introducing Cactal | Acme Journal`. Site metadata must be static and cannot contain `{{ ... }}` template tokens because it has no page query or request context.

Without authored metadata, Cactal uses the website name as the title and an empty description.

## Set static page metadata

Add `metadata` directly to the top-level `definePage` object. Page values override site defaults.

```tsx pages/about theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/about',
	metadata: {
		title: 'About us',
		description: 'Meet the team behind Acme.',
		canonical: '/about',
		openGraph: { type: 'profile' }
	},
	render: () => <main>About Acme</main>
})
```

The rendered HTML title is `About us | Acme Journal` when the site-level title template above is present.

## Build metadata from CMS content

Page metadata strings can contain `{{ ... }}` tokens. Cactal runs the page's declared CMS queries first, then resolves metadata and renders the page. A query's key becomes the template's root name.

```tsx pages/post theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/blog/:slug',
	queries: ({ cms, param }) => ({
		post: cms.posts.get({
			slug: param.path('slug'),
			depth: 1
		})
	}),
	metadata: {
		title:
			'{{ post.fields.seo_title || post.fields.title || "Untitled" }}',
		description:
			'{{ post.fields.seo_description || post.fields.excerpt || "Read the latest from Acme." }}',
		canonical: '/blog/{{ post.slug }}',
		openGraph: {
			type: 'article',
			url: '/blog/{{ post.slug }}',
			images: [
				{
					url: '{{ post.fields.cover.url }}',
					alt: '{{ post.fields.cover.altText || post.fields.title }}'
				}
			]
		},
		twitter: {
			card: 'summary_large_image'
		},
		structuredData: [
			{
				'@context': 'https://schema.org',
				'@type': 'Article',
				headline: '{{ post.fields.title }}',
				description:
					'{{ post.fields.seo_description || post.fields.excerpt }}',
				datePublished: '{{ post.meta.publishedAt }}',
				dateModified: '{{ post.meta.updatedAt }}',
				image: '{{ post.fields.cover.url }}',
				mainEntityOfPage: '/blog/{{ post.slug }}'
			}
		]
	},
	render: ({ data }) => (
		<article>
			<h1>{data.post.fields.title}</h1>
			<p>{data.post.fields.excerpt}</p>
		</article>
	)
})
```

In this example, `posts` is the CMS collection slug, `post` is the page query key, and field keys such as `seo_title` and `cover` come from the collection schema. `depth: 1` makes one level of referenced CMS items available to the page and to metadata templates.

### Available references

| Reference                                 | Value                                            |
| ----------------------------------------- | ------------------------------------------------ |
| `<query>.id`                              | CMS item id returned by a `get` query            |
| `<query>.slug`                            | CMS item slug                                    |
| `<query>.fields.<key>`                    | A collection field value                         |
| `<query>.meta.publishedAt`                | Publish timestamp or `null`                      |
| `<query>.meta.createdAt`                  | Creation timestamp                               |
| `<query>.meta.updatedAt`                  | Last update timestamp                            |
| `<query>.fields.<reference>.fields.<key>` | A hydrated referenced item's field               |
| `params.<name>`                           | A matched route parameter, such as `params.slug` |
| `query.<name>`                            | A request query parameter, such as `query.ref`   |

The first path segment can be any page query key, not only `post`. A list query exposes its result object under the same key, but metadata templates do not support array indexing. Prefer a `get` query for metadata about one detail page.

Reference paths use dot notation and identifier-like segments. Bracket access, numeric indexes, and query parameter names containing characters such as `-` are not supported. The unsafe path segments `__proto__`, `constructor`, and `prototype` are rejected.

## Template syntax

Templates work in string values anywhere inside page metadata, including nested Open Graph data, alternate URLs, and JSON-LD.

| Pattern               | Example                                                | Result                       |
| --------------------- | ------------------------------------------------------ | ---------------------------- |
| Whole-value reference | `'{{ post.fields.title }}'`                            | The referenced value         |
| Interpolation         | `'Article: {{ post.fields.title }}'`                   | Text with the value inserted |
| Reference fallback    | `'{{ post.fields.seo_title \|\| post.fields.title }}'` | First available value        |
| Literal fallback      | `'{{ post.fields.title \|\| "Untitled" }}'`            | Reference or static text     |
| Multiple tokens       | `'{{ params.slug }} via {{ query.ref }}'`              | Both values inserted         |

Fallback terms may be dotted references or double-quoted JSON string literals. The fallback operator is intentionally narrow: it does not execute JavaScript. `null` and missing values advance to the next term; empty strings, `0`, and `false` count as resolved values.

When one token occupies the entire string, Cactal preserves the resolved value's native type. This is useful for values inside structured data:

```tsx theme={null}
structuredData: [
	{
		'@context': 'https://schema.org',
		'@type': 'Article',
		wordCount: '{{ post.fields.word_count }}'
	}
]
```

When a token is mixed with surrounding text, strings, finite numbers, and booleans are converted to text. Arrays and objects cannot be interpolated into surrounding text. For CMS images and files, reference the URL explicitly, for example `{{ post.fields.cover.url }}` or `{{ post.fields.brochure.url }}`.

<Warning>
  Template tokens are not JavaScript. Function calls, operators other than `||`, ternaries, bracket access, nested tokens, and arbitrary expressions fail validation. For example, `{{ post.fields.title.toUpperCase() }}` and `{{ post['fields'].title }}` are invalid.
</Warning>

### Missing values

Metadata resolution is best effort at request time:

* A missing reference advances through its fallback chain.
* If no term resolves, Cactal omits that value rather than failing the page.
* Omitting a page value reveals the corresponding site or platform default.
* An unresolved array entry is removed; an unresolved object property is omitted.
* Invalid social images and non-object or non-serializable JSON-LD entries are omitted without breaking unrelated metadata or page rendering.

Use literal fallbacks for required titles and descriptions. Optional metadata such as a CMS social image can be left unresolved safely.

## Metadata reference

### Top-level fields

| Field            | Type      | Output and behavior                                                         |
| ---------------- | --------- | --------------------------------------------------------------------------- |
| `title`          | string    | `<title>` and the default Open Graph and Twitter title                      |
| `titleTemplate`  | string    | Replaces every `%s` with a page title; inherited from site metadata         |
| `description`    | string    | Description meta tag and the default social description                     |
| `keywords`       | string\[] | Comma-separated keywords meta tag                                           |
| `canonical`      | string    | Shorthand for the effective canonical URL; wins over `alternates.canonical` |
| `language`       | string    | The document's `<html lang>` value; defaults to `en`                        |
| `robots`         | object    | Search crawler directives                                                   |
| `openGraph`      | object    | Open Graph properties                                                       |
| `twitter`        | object    | Twitter/X card metadata                                                     |
| `alternates`     | object    | Canonical, language, media, and content-type alternates                     |
| `structuredData` | object\[] | One JSON-LD script per object                                               |

### Robots

```tsx theme={null}
robots: {
	index: true,
	follow: true,
	noarchive: false,
	nosnippet: false,
	noimageindex: false,
	nocache: false,
	notranslate: false,
	maxSnippet: 160,
	maxImagePreview: 'large',
	maxVideoPreview: -1,
	unavailableAfter: 'Wed, 15 May 2026 15:00:00 GMT'
}
```

`index` and `follow` emit their positive or negative forms. The boolean fields prefixed with `no` emit a directive only when `true`. `maxSnippet` and `maxVideoPreview` must be integers greater than or equal to `-1`; `maxImagePreview` is `none`, `standard`, or `large`.

Setting the effective `robots.index` to `false` excludes a route from Cactal's generated sitemap. A site-level `false` therefore produces an empty sitemap unless a page overrides it with `true`.

### Open Graph

| Field         | Type      | Meaning                                               |
| ------------- | --------- | ----------------------------------------------------- |
| `title`       | string    | `og:title`; defaults to the generic title             |
| `description` | string    | `og:description`; defaults to the generic description |
| `url`         | string    | `og:url`                                              |
| `siteName`    | string    | `og:site_name`                                        |
| `type`        | string    | `og:type`, such as `website` or `article`             |
| `images`      | object\[] | Ordered `og:image` entries                            |

Each image requires `url` and can include `secureUrl`, `alt`, `type`, `width`, and `height`.

### Twitter/X

| Field         | Type   | Meaning                                              |
| ------------- | ------ | ---------------------------------------------------- |
| `card`        | string | `summary`, `summary_large_image`, `app`, or `player` |
| `site`        | string | Site account, usually including `@`                  |
| `creator`     | string | Content creator account                              |
| `title`       | string | Defaults to the generic title                        |
| `description` | string | Defaults to the generic description                  |
| `image`       | object | `{ url, alt? }`                                      |

When Twitter metadata has no image, Cactal uses the first Open Graph image. It also selects `summary_large_image` when it derives that image and no card was set. Explicit Twitter values always win.

### Alternate links and canonical URLs

```tsx theme={null}
alternates: {
	canonical: '/blog/hello-world',
	languages: {
		en: '/blog/hello-world',
		fr: '/fr/blog/bonjour'
	},
	media: {
		'only screen and (max-width: 600px)': '/mobile/blog/hello-world'
	},
	types: {
		'application/rss+xml': '/feed.xml'
	}
}
```

`languages`, `media`, and `types` are maps from the corresponding `hreflang`, `media`, or `type` attribute to a URL. The top-level `canonical` field is a convenient alternative to `alternates.canonical`; when both are present, the top-level field wins.

Metadata URLs must be absolute `http` or `https` URLs, or site-relative paths beginning with `/`. Cactal rejects unsafe schemes, protocol-relative URLs such as `//cdn.example.com/image.png`, control characters, and relative paths without a leading slash.

If a published page has no explicit canonical, Cactal uses its full requested HTTPS URL, including query parameters. Draft previews do not emit canonical links.

### Structured data

`structuredData` accepts an array of JSON objects and renders each as a separate `<script type="application/ld+json">`. Values must be JSON serializable: strings, finite numbers, booleans, `null`, arrays, and plain objects. Functions, `undefined`, `BigInt`, `Date` objects, circular references, and unsafe keys are rejected.

Cactal safely serializes JSON-LD so values containing HTML or `</script>` cannot break out of the script element. Template tokens can appear in any string nested inside a structured-data object.

## Inheritance and rendering

Cactal resolves final metadata in this order:

1. Platform defaults, including the website name as the title.
2. Static metadata from `website_config`.
3. Metadata from the matched page or custom not-found page.
4. Page metadata template tokens using resolved CMS and request data.
5. The inherited `titleTemplate`, if the page supplies a title.
6. Open Graph and Twitter fallbacks.

Page values replace site values at the top level. `robots`, `openGraph`, `twitter`, and `alternates` merge by nested field so a page can override one setting without repeating the whole site object. Arrays such as `keywords`, `openGraph.images`, and `structuredData` replace the inherited array when supplied.

All metadata text and HTML attributes are escaped before rendering. Invalid URLs are omitted. Draft previews additionally force `noindex, nofollow`, suppress canonical links, and remove `og:url`, even if the source requests otherwise.

## Generated crawler files

Cactal owns the crawler files for every website. On a published host:

* `/robots.txt` allows all crawlers and points to the host's `/sitemap.xml`.
* `/sitemap.xml` lists every indexable static route.
* An indexable dynamic route is listed when Cactal can enumerate it from published CMS items.

The `metadata.robots` object controls a page's `<meta name="robots">` directives and sitemap inclusion. It does not customize the contents of `/robots.txt`. Cactal combines site and page metadata before deciding whether a route is indexable, so either level can supply `robots.index`.

### Dynamic CMS routes

Cactal can enumerate a dynamic route when all of these conditions are true:

1. The route contains exactly one path parameter.
2. Exactly one page `get` query looks up an item by `slug` or `id`.
3. That lookup value is bound to the same path parameter with `param.path(...)`.

```tsx pages/post theme={null}
export default definePage({
	route: '/blog/:slug',
	queries: ({ cms, param }) => ({
		post: cms.posts.get({ slug: param.path('slug') })
	}),
	render: ({ data }) => <h1>{data.post.fields.title}</h1>
})
```

Only published, non-deleted items from `posts` produce `/blog/<slug>` entries. A dynamic route is omitted when it uses a list query, has multiple path parameters, has no matching `get` query, or has more than one matching lookup. The page remains routable even when Cactal cannot infer its sitemap URLs.

See [Build pages with CMS data](/docs/guides/build-pages-with-cms-data) for the complete query API and request-parameter behavior.

### Timestamps, large sites, and caching

Each sitemap entry includes `<lastmod>`. Static routes use the published deployment timestamp, while CMS-backed dynamic routes use the item's `updatedAt` timestamp.

A sitemap with at most 50,000 URLs is returned directly from `/sitemap.xml`. Larger sites receive a sitemap index whose entries point to `/sitemap-1.xml`, `/sitemap-2.xml`, and additional shards of at most 50,000 URLs each.

Sitemap responses are publicly cacheable for up to one hour. A recently published deployment or CMS update can therefore take up to an hour to appear to a client or intermediary holding a cached response.

### Draft protection and reserved paths

Draft hosts always return crawler-safe behavior regardless of authored metadata:

* Every preview response includes `X-Robots-Tag: noindex, nofollow`.
* `/robots.txt` returns `Disallow: /`.
* `/sitemap.xml` and sitemap shard paths return `404`.

`/robots.txt`, `/sitemap.xml`, and `/sitemap-<n>.xml` are reserved platform paths. They cannot be page routes or redirect sources. Use metadata to control page indexing instead of trying to replace these generated files.

## Source requirements

Cactal statically extracts metadata so it can validate, persist, and resolve it without executing user-authored metadata code. Both `definePage` and `defineWebsiteConfig` therefore require a direct default export and inline metadata:

```tsx theme={null}
export default definePage({
	route: '/about',
	metadata: {
		title: 'About'
	},
	render: () => <main>About</main>
})
```

Metadata can contain JSON-like literals and page template strings. It cannot use:

* Imported or local metadata objects
* Object or array spreads
* Computed or duplicate keys
* Template literals using backticks
* Functions or function-valued metadata
* Calls, concatenation, or other JavaScript expressions
* Non-JSON values such as `undefined` or `new Date()`

Run `POST /v1/websiteSourceCode/head/check` before publishing. Invalid source shapes and template syntax return a framework diagnostic with the page id and metadata path, such as `$.metadata.openGraph.images[0].url`.

## Edit metadata through the API

You can upsert the whole source file or use the semantic metadata endpoint. `PATCH /v1/websiteSourceCode/metadata` with a `page` replaces that page's complete `metadata` object and rewrites only that property in source.

The endpoint does not add CMS queries. The target page must already declare every query referenced by the new metadata.

```bash Set CMS-driven page metadata theme={null}
curl -X PATCH 'https://api.cactal.ai/v1/websiteSourceCode/metadata' \
  -H "Authorization: Bearer $CACTAL_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "websiteId": "V1StGXR8_Z5jdHi6B-myT",
    "page": "post",
    "metadata": {
      "title": "{{ post.fields.seo_title || post.fields.title || \"Untitled\" }}",
      "description": "{{ post.fields.seo_description || post.fields.excerpt }}",
      "canonical": "/blog/{{ post.slug }}"
    },
    "leaseToken": "exampleLeaseToken00000000000000000000000000"
  }'
```

Omit `page` from the same endpoint to edit static site defaults. It creates `website_config` when needed and rejects template tokens. Both modes replace the entire metadata object, so send every field you want to keep. The endpoint requires the current edit lease and returns `metadata`, `metadataChanged`, `version`, and `changed`.

## Troubleshooting

| Problem                                                | Cause                                                         | Fix                                                                             |
| ------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Page check rejects `{{ title }}`                       | References require at least two dotted segments               | Use a query root such as `{{ post.fields.title }}`                              |
| Page check rejects a function or imported object       | Metadata must be statically extractable                       | Use an inline literal and framework tokens                                      |
| Website config rejects a token                         | Site defaults have no page data context                       | Move the tokenized value to page metadata                                       |
| A title or description falls back to the site default  | Its token did not resolve or resolved to an incompatible type | Check the query key, field key, publish state, and add a literal fallback       |
| A CMS image does not render                            | The metadata URL received the whole image object              | Reference its URL, such as `{{ post.fields.cover.url }}`                        |
| A referenced author field is missing                   | The page query did not hydrate the reference                  | Add `depth: 1` or greater to the CMS query                                      |
| A URL tag is absent                                    | The URL was empty, unsafe, or not absolute/site-relative      | Use `https://example.com/path` or `/path`                                       |
| Draft metadata says `index: true` but remains noindex  | Draft protection always wins                                  | Publish the page before checking production crawler metadata                    |
| A dynamic page is missing from the sitemap             | Its route and query plan are not safely enumerable            | Use one path parameter and exactly one matching `get` query by slug or id       |
| A newly published CMS page is missing from the sitemap | The item is still a draft or a sitemap response is cached     | Publish the item, verify its `publishedAt`, and allow up to one hour for caches |
| A `/robots.txt` page or redirect fails validation      | The path is reserved for Cactal's generated crawler file      | Control indexing with `metadata.robots`                                         |

## Next steps

<Columns cols={2}>
  <Card title="Edit source code" icon="code" href="/docs/guides/edit-source-code">
    Acquire a lease, update source or metadata semantically, validate, and publish.
  </Card>

  <Card title="Manage CMS content" icon="database" href="/docs/guides/manage-cms-content">
    Create the collections and fields used by dynamic metadata.
  </Card>

  <Card title="Build pages with CMS data" icon="blocks" href="/docs/guides/build-pages-with-cms-data">
    Declare typed CMS queries, bind route parameters, and render results.
  </Card>

  <Card title="Configure routes and redirects" icon="route" href="/docs/guides/configure-routes-and-redirects">
    Define clean paths, matching behavior, and redirect migrations.
  </Card>

  <Card title="Preview drafts" icon="eye" href="/docs/guides/preview-drafts-and-versions">
    Review metadata safely on the protected draft host.
  </Card>

  <Card title="Source code concepts" icon="file-code" href="/docs/concepts/source-code">
    Understand page files, website config, versions, and publishing.
  </Card>
</Columns>
