---
title: Handle SEO and metas
description: Give every page a real title and description, keep the pages that should stay out of the index out of it, and get detail pages right.
updated: 2026-08-19
---

Most SEO work on a Quilium site is not clever, it is systematic: every page has a title and a description, detail pages derive theirs from the entry, and the pages that exist for the machine rather than the visitor are excluded.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Cairn.</b> Its content-kit page is active, hidden from the navigation and explicitly noindex, which is exactly the case this guide is about. The translated metas come from <b>Oscilla</b>, the robots tabset from <b>Lisière</b>.</div></div>

## 1. Metas are page data

A page carries its metas as page-level fields, written JSON-stringified through `update-page`. They are content, so an editor owns them, not the template.

`metas` is a small free-form object, and the keys our templates actually read are `title`, `description`, `keywords` and `robots`. Cairn's content-kit page carries two of them:

```json
{ "description": "Page de référence interne — vitrine des blocs de contenu réutilisables du site Cairn.",
  "robots": "noindex, nofollow" }
```

`robots` is a raw directive string, passed through to the meta tag as written.

**Metas are translatable**, per locale, in the same shape — Oscilla's home page carries a French description at the root and its English counterpart under `translations.en.metas`. Like every page write, one locale per call, alongside the root data.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>There is no built-in <code>ogImage</code>, <code>canonical</code> or SEO tabset.</b> <code>metas</code> is all the platform gives you. Anything richer is a field you declare yourself on the page-type — and then it is yours to render.</div></div>

Lisière declares exactly that, a `robots` tabset of six checkboxes reused across every page-type through a YAML anchor, so the editor gets toggles instead of a directive string to spell correctly:

```yaml
_robots: &_robots
  noIndex:
    type: checkbox
    label: No index
  noFollow:
    type: checkbox
    label: No follow
  excludeSitemap:
    type: checkbox
    label: Exclude from the sitemap

default:
  name: Default
  template: default.liquid
  tabsets:
    robots:
      name: Robots
      fields:
        <<: *_robots
```

Declared that way, the flags are read as `page.custom.noIndex`, flat — the tabset name is not part of the path.

![A page's settings in the back-office: the page active, hidden from the navigation, and the No index flag ticked in the Robots tab.](/thumbs/64528400-aa3f-11f1-836b-c5d9c6fb16b8.png)

## 2. Render them in the root layout

Written once, in `layout.liquid`, they apply to every page:

```liquid
<title>{% block title %}{{ page.title }}{% endblock %}</title>
```

A detail page has no title of its own until its query resolves, which is why the pattern is to assign the title **outside** any block, from the resolved entry:

```liquid
{% if page.custom.article and page.custom.article.size > 0 %}
  {% assign pageTitle = page.custom.article[0].title %}
{% endif %}
```

## 3. Keep the right pages out of the index

Three different situations, three different answers, and mixing them is how a site disappears from search results.

| Situation | What to do |
|---|---|
| A page that exists for editors, a content kit, a component gallery | active, `visible: false`, and noindex |
| A thank-you page after a form | active, `visible: false`, and noindex |
| The hidden page behind a detail route | active, `visible: false`, **indexable**: it is what serves every entry |
| A page that should not exist any more | a redirection, not a deactivation |

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Hidden is not noindex, and neither is inactive.</b> <code>visible: false</code> only removes a page from the menu. An <code>inactive</code> page 404s everywhere. Neither one is the way to say "do not index this".</div></div>

## 4. Multilingual pages need alternates

Each locale is its own URL, so each page must point at its counterparts with `hreflang`. The mechanics are in [Run a multilingual site](/en/docs/multilingual-site); the SEO rule is simply that every localised page declares every alternate, including itself.

## 5. Let an agent do the sweep

Writing one description is a judgement call. Writing the forty that are missing is an inventory job, and that is the part to hand over.

```prompt
List every page of this site whose metas carry no description,
with its title and the first paragraph of its content.
Then draft one description per page, under 160 characters,
in that page's own language. Show me the list before writing anything.
```

Asking for the content, not just the title, is what makes the drafts usable: the agent reads the real copy through `get-page-with-content` instead of paraphrasing a title back at you. And the review step is not ceremony — a description is a promise made to someone reading a result list, and one that reads beautifully while describing the wrong page costs more than the empty field it replaced.

Two things the agent cannot infer, so state them: metas are a page write, **one locale per call**, so a bilingual site is two passes and not one; and the pages that should stay out of the index are not missing a description, they are a different job — section 3.

## 6. Check before you ship

Load the live URL, not localhost, and look at three things: the title in the tab, the description in the page source, and the response code of a URL that should not exist. See [Publish and go live](/en/docs/publish-and-go-live).

## 7. What the platform does not do for you

Three things people expect to find in the settings. Only one of them is there, and the other two are yours to write — which is worth knowing before you go looking.

### `robots.txt` is a static asset

It is a file in `public/`, shipped by `quilium push` like the CSS and the favicon, and served from the root:

```text
# Allow crawling of all content
User-agent: *
Disallow:
```

Nothing declares it and nothing generates it — which also means it is **not per locale**. `/robots.txt` answers; `/fr/robots.txt` is a 404, and that is correct: the file belongs to the host, not to a language.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>The permissive default is deliberate, and it is not a publication switch.</b> Every demo site ships the allow-everything version above. If a staging host must stay out of the index, that is a <code>Disallow: /</code> on <i>that host's</i> file — and remembering to take it out on the day you go live. Keeping a page out of the index is a different job, done per page: see section 3.</div></div>

There is **no generated `sitemap.xml`** either — `/sitemap.xml` is a 404. The `sitemap` variable your templates use for the navigation is the page tree in Liquid, not a file a crawler can fetch. If you want the XML, it is a page-type with its own `headers:` and a template that writes the body, the same shape as the JSON feed in [Build an events agenda](/en/docs/events-agenda).

### Open Graph and JSON-LD are yours

No defaults, no toggle, no generated tags. What exists is the metas from section 2, and everything else is markup you write once in `layout.liquid` — where it costs a few lines, since the values are already resolved:

```liquid
<meta property="og:type" content="website">
<meta property="og:title" content="{% if page.metas.title != blank %}{{ page.metas.title }}{% elsif page.title != blank %}{{ page.title }}{% else %}{{ globals.siteName }}{% endif %}">
<meta property="og:description" content="{% if page.metas.description != blank %}{{ page.metas.description }}{% else %}{{ globals.tagline }}{% endif %}">
<meta property="og:locale" content="{{ locale.slug }}">
```

That is Vesper's, and it is the only demo site that carries any — the fallback chain is the interesting part, not the tags: page meta, then page title, then the site name from a global field, so a page nobody filled in still shares as something.

**Structured data is on nobody.** No demo site ships JSON-LD, and the platform emits none. A `<script type="application/ld+json">` in the layout, or in the page-type template of the thing being described, is the whole of it — an article's fields are already resolved as `page.custom.article.*` at that point, so the block writes itself. Mind the `| json` rule from the events agenda while you do: a value that resolves to nothing emits nothing, and produces invalid JSON inside a valid page.

## Related

<div class="q-cards">
  <a class="q-card reco" href="/en/docs/set-up-redirections"><span class="k">Recommended</span><span class="t">Set up redirections</span><span class="d">Keep old URLs alive after a rename.</span></a>
  <a class="q-card" href="/en/docs/multilingual-site"><span class="t">Run a multilingual site</span><span class="d">Alternates and locale-aware links.</span></a>
  <a class="q-card" href="/en/docs/page-types"><span class="t">Page-type schema</span><span class="d">Where page-level fields are declared.</span></a>
</div>
