---
title: The layout and the home page
description: Port the prototype's chrome into a Liquid layout, then build the first page — a home that is an index of the articles collection.
updated: 2026-07-31
prev: ai-architect
next: ai-static-page
---

From here the site is built one page at a time: template, then content, then look at it. The home page comes first because it carries the chrome every other page reuses.

<div class="q-callout q-callout-note"><i class="fa-solid fa-flask lead"></i>
  <div><b>Live example: Lisière.</b> Its home page is not a landing page — it opens straight onto the content, through two queries: the featured piece, and the latest ones.</div></div>

## What you will have at the end

A root layout carrying the design tokens, a dispatcher, the element templates the home page needs, and `/` rendering with real content.

## Before you start

[The architecture pass](/en/docs/ai-architect) done: the types, the pages and the routes all exist on the site.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>Compact the thread before you start.</b> The architecture pass filled your agent's window with YAML it will not read again — from here it reads HTML and writes Liquid. Compacting, or opening a fresh session in the same folder, costs you nothing: the model is on the site and the rules are in your project file. Long threads are the most common reason an agent starts forgetting what it agreed to.</div></div>

## 1. Ask what the page takes

The previous tutorial answered *what the site is made of*. This one opens on a different question, and asking it out loud is worth one turn:

```prompt
We start with the layout and the home page.
Before writing anything: going block by block through the prototype's home,
what do you have to template, and what content do I have to insert —
blocks and collection entries?
```

Nothing in there about matching the markup or the class names: that is in the project file you wrote in [the first tutorial](/en/docs/ai-setup), and repeating it would only make the prompt longer. What the prompt does carry is `block by block`, which pins the answer to the prototype rather than to the model, and the two halves of the question — template, content — which is what makes the answer split along the line the work actually splits along.

```answer
Nothing to change in the settings — the types are all there. The work
splits in two: five element templates, and the content to fill them.

Home, block by block

- Header + mobile menu → layout.liquid
- Featured → elements/featured.liquid, 1 block, 1 article flagged Featured
- As it flows → elements/articleFeed.liquid, 1 block, 6 articles
- Browse by section → elements/sectionList.liquid, 1 block, 4 sections
- Review band → elements/reviewBand.liquid, 1 block
- Newsletter band → elements/newsletterBand.liquid, 1 block
- Footer → layout.liquid

Also on the template side

- public/css/site.css, public/js/site.js and the favicon, lifted from
  proto/assets — the brand motifs and the mobile menu live in there.
- _contents.liquid and default.liquid ship with the starter and need no
  change. Adding the five elements is adding five files, nothing else.

Also on the content side

- 6 images, uploaded before anything else: entries reference media by
  uuid, so an entry authored before its image keeps an empty image field.
- 4 contributors. The home only prints a name, but author is a relation —
  the entry has to exist for the byline to resolve.
- 7 articles: 1 lead + 6 in the feed. Five carry a cover, two are
  text-only — that is an empty image field, not a variant.
- Everything Actif. The three queries filter on it, and an inactive entry
  is not an error, it is an empty page.

Two things to settle before I start

1. The header's four section links come from a collection, and a layout
   carries no query field. I need to confirm how a collection is reached
   from the layout before I write the header.
2. The prototype says the feed excludes the featured piece. The query
   approved last pass does not: the lead is the most recent Actif piece,
   so it would open the home and repeat one block down.
```

Read the two columns as two places rather than two lists. The left-hand one is files on your disk, and `quilium push` carries them. The right-hand one is data in the CMS, which no push ever touches. Nothing on the left renders without something on the right — five templates over four empty collections is a page that returns 200 and shows an empty `<div>`.

The left column is also the shortest it will ever be. Five content-types, five files, and the dispatcher already in place: adding a block to this site is adding one file, which is the whole point of the architecture the last tutorial settled.

Then there are the two questions at the bottom, and they are the reason to ask before building. The first is a real gap — the header needs data from a collection, and a layout has nowhere to declare a query. The second is a mistake made one tutorial ago: the feed you approved takes the six most recent pieces, and the lead is the most recent of all, so it would appear twice on the page. Both are answered below, in the order the work runs.

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>A question costs one turn; a wrong build costs the afternoon.</b> Neither of those two points is findable while writing Liquid — the first only shows up when you go looking for where the header's data comes from, the second only by reading a comment in the prototype against a query written a tutorial earlier. An agent asked to build goes straight past both.</div></div>

## 2. The root layout

The approval and the two answers travel in one message — and one of them is handed over as a problem, not a solution:

```prompt
Go. Two things settled first: the sections in the header come from the
collection, so find a way for the layout to reach one — and the feed has
to exclude the featured piece.
Layout only for now: header, mobile menu, footer.
```

`find a way` is the load-bearing phrase. Nothing in the skills answers it: a `layout.liquid` declares no fields of its own, and no page of the documentation says how it reaches a collection. Handing over the problem instead of a guessed solution is what sends an agent to measure rather than assume.

```answer
⏺ get-skill × 3
  ⎿ reference-templates: a layout sees globals, sitemap, page, content —
    no query of its own. And for nav items it says the opposite of what I
    need: their custom fields are exposed, but "query fields are null,
    they are not resolved for the navigation".
    reference-global-fields and reference-fields-catalog: neither states
    that a query field is allowed in the global fields.

⏺ Undocumented either way, so I am probing instead of guessing. Declared
  a query field on the section collection under the global fields, then
  one debug line in the layout:

  <!--DBG sections=[{{ globals.sections | json }}]-->
  →  DBG sections=[[]] size=[0] ga=["UA-XXXXX-X"]

  [] and not nothing: the query runs, the collection is simply empty.
  globals.sections is the answer. Debug line removed.

⏺ update-setting(global-fields)
  ⎿ the partial patcher rejects this setting type — global fields are a
    single object, so it is a full read-merge-write

⏺ update-settings-partial(articleFeed…pieces)
  ⎿ added featured: Normal to the where — the lead no longer repeats

⏺ Wrote layout.liquid. Copied site.css, site.js and the favicon out of
  proto/assets unchanged.
```

Three of the four files were **copied, not written**. The prototype's stylesheet already carries the brand motifs — the edge rule, the stamp, the threshold bullet — and its script already carries a working focus trap. Retyping either is an opportunity to break it. Only `layout.liquid` is authored, and the design tokens are inlined in its `tailwind.config` rather than kept in a file of their own.

The menu is where the architecture decision comes due. Sections are a collection, the other entries are pages, so the nav is built from two sources:

```liquid
{% assign subscribeUrl = 'subscribe' | url %}

{% for s in globals.sections %}
  <a class="nav-link" href="{{ 'section' | url: section: s }}">{{ s.name }}</a>
{% endfor %}
{% for item in sitemap.main.children %}
  {% unless item.slug == blank or item.url == subscribeUrl %}
    <a class="nav-link" href="{{ item.url }}">{{ item.navTitle }}</a>
  {% endunless %}
{% endfor %}
```

Two entries are skipped: the home, recognised by its **empty slug**, and Subscribe, which is the button rather than a link. Note how the second one is excluded — by comparing against its own resolved route, never against `/newsletter`. A hardcoded path there would survive every test and break the day the slug is translated.

![The rendered site chrome on an otherwise empty page: the sticky header with the lisière wordmark, the issue stamp, two menu entries and the Subscribe button; below it the dark footer with the wordmark, the baseline, the review column and the copyright line.](/thumbs/4c5e8100-aa3f-11f1-b26b-79f6caaa8edf.png)

That is the whole page. The header and the footer are real, and everything between them is the empty `<main>` — no content-type has a template yet, so the dispatcher has nothing to include.

Two absences in that render are deliberate. There is no **Sections** column and no separator in the menu, because both are guarded on `globals.sections.size` and the collection is still empty — an empty collection has to degrade to nothing, not to an orphan divider. And there is no **Follow** column: the prototype links to social profiles that do not exist, so those became two global fields left empty, and the column renders only once someone fills them.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>Never hardcode a path or a page ID in a template.</b> Internal links go through the named route, so moving a page later rewrites every link on its own. Same rule for the year in the footer.</div></div>

## 3. The content, before the templates

The usual order is templates first, then content — you want to see one block render before you author fifty. Here it is worth inverting, and saying why in the prompt:

```prompt
Go, but content before templates: I want the elements written against real
data rather than against a guess about its shape.
Media first, then the sections, the contributors, the articles, then the
five blocks on the home page.
```

Only the first sentence is yours to write. The rest of that prompt is a to-do list the agent produced itself one section ago, repeated here because an agent that has just compacted its thread no longer has it — and because the order inside it matters: an entry authored before its image keeps an empty image field for good.

```answer
⏺ quilium media upload proto/assets/img/*.png  (×6)
  ⎿ 6 uuids. The MCP upload takes a URL, not a local path — the CLI is the
    only way in for a file on disk. Alt text set on each from the
    prototype's own alt attributes.

⏺ create-itemset-item(section) ×4, (contributor) ×4, (article) ×7
  ⎿ 4 sections, 4 contributors, 7 pieces — 1 flagged Featured, 6 for the
    feed. Five carry a cover, two do not.

⏺ One data change worth flagging. The prototype's feed is hand-ordered, and
  its dates do not match that order — the second card is dated three days
  before the third. A query sorts by publishedAt desc, so I moved one date
  (The watershed line, June 2 → June 8) to make the query reproduce the
  prototype's layout. Say the word if the date was the intent.

⏺ add-content-to-page(row1) ×5
  ⎿ featured, articleFeed, sectionList, reviewBand, newsletterBand.
    Nothing to fill on the three query fields — they resolve at render.
```

The two wrappers are not the same, and mixing them is a 422: collection entries take `data`, content blocks take `custom`. Images are the other shape trap — `[{id}]` for media, a flat `['uuid']` for every other reference.

That date change is the kind of thing worth reading twice. Nothing was broken: the prototype is a static file where the author placed the cards by hand, and the review's real editor would have done the same. The moment that layout became a query, the order stopped being a design decision and became a property of the data — so either the data carries it, or the page renders in an order nobody chose.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>The one order the API cannot write.</b> A collection marked <code>sortable</code> is ordered by dragging its rows in the back-office, and that gesture has no MCP equivalent — <code>_order</code> is read-only from the outside. Four sections that nobody has dragged come back in whatever order the database feels like, and it changes between renders. The fix is a second sort key: <code>_order</code> first, then <code>created_at</code>, so untouched rows keep the order they were created in and a later drag still wins.</div></div>

## 4. The dispatcher and the five elements

The dispatcher already ships with the starter, and it is one line — it loops a drop-zone and includes each block's own template:

```liquid
{% for content in section %}{% if content.template %}{% include content.template %}{% endif %}{% endfor %}
```

That single line is what makes the content model extensible: a new content-type is a new file in `elements/`, and nothing else changes. Which is why the prompt at this stage is as short as it will ever be:

```prompt
Now the five elements. Nothing else.
```

Everything that used to go in a prompt — match the markup, keep the classes, no invented links — is in the project file by now. `Nothing else` is the only word doing work: it stops the agent from carrying on into the section pages while it has momentum.

```answer
⏺ get-skill × 3
  ⎿ reference-fields-catalog, reference-dates, practices-patterns-sortable

⏺ Wrote elements/featured.liquid, articleFeed.liquid, sectionList.liquid,
  reviewBand.liquid, newsletterBand.liquid — plus _date.liquid, see below.

⏺ Three things the page told me:

  1. A query does not hydrate its relations. `author` and `section` came
     back as bare uuids, so the byline printed nothing. `with:` on the
     query config resolves them — it is one bullet in the catalog, and the
     same catalog says the opposite two sections earlier under `items`
     ("query only hydrates .id, resolve it by hand in the template").
     Patched both queries with `with: [section, author]`.

  2. Liquid indexes an array by a variable, never by an expression.
     spans[forloop.index0 | minus: 1] silently read the wrong slot: the
     first card lost its width and the last one rendered one word per line.
     Two lines instead of one.

  3. The date filter runs on MomentJS, whose locale here is French — MMMM
     printed "juin 12, 2026" on an English site. The filter's second
     argument is an interval, not a locale, and the site carries no locale
     object to override. Numeric tokens are locale-free, so the month name
     is looked up in _date.liquid and included from both templates that
     print a date.
```

None of those three is findable by reading. The first one looks like an editor who forgot to fill a field, the second like a CSS problem, the third like a typo — and all three are invisible until the page is on screen. The first is also the more interesting failure, because the answer *was* in the documentation: one line of it said `with:`, another said to resolve the relation by hand. A page on screen settles that argument in one render.

The one genuine design decision in there is the feed's grid. The prototype's press layout is asymmetric on purpose: one piece full width, then five in a twelve-column band at 7-5-4-4-4. Those five numbers are a design decision, not an editorial one, so they stay in the template as a plain list rather than becoming five fields nobody would ever want to edit:

```liquid
{% assign spans = '7|5|4|4|4' | split: '|' %}
...
{% assign spanIndex = forloop.index0 | minus: 1 %}
{% assign span = spans[spanIndex] %}
<article class="… md:col-span-{{ span }}">
```

<div class="q-callout q-callout-tip"><i class="fa-solid fa-lightbulb lead"></i>
  <div><b>Two lines, not one.</b> <code>spans[forloop.index0 | minus: 1]</code> does not error — Liquid parses it as <code>spans[forloop.index0]</code> and reads the wrong slot for every single card. Assign the index first, then index the array. The same rule applies anywhere you were about to filter inside brackets.</div></div>

## 5. Look at it

```bash
npm run quilium
```

The two servers from [the first tutorial](/en/docs/ai-setup) are the whole check: the prototype on 9055, the site on 9056, side by side in two tabs.

What you are checking, in this order: the page returns 200, no Liquid leaks into the HTML (`{{`, `{%`, `[object Object]`, `undefined`), the image count matches what you authored, and only then whether it looks like the prototype.

```bash
curl -s localhost:9056/ | grep -c '{{\|{%\|\[object Object\]\|undefined'
```

Zero is the only acceptable answer. A leaked `{%` means a tag the engine did not recognise; a leaked `undefined` means a field name that does not exist on the object you think you are reading.

The other place to look is the back-office, which previews the same page without a terminal:

![The Quilium back-office on the home page: the site tree on the left with Home selected, and the preview on the right showing the featured piece — the Ideas kicker, the headline, the byline and the cover.](/thumbs/4c608a50-aa3f-11f1-8c81-3fc27e7adefd.png)

Two things in that tree are worth a second look. **Section** and **Article** are marked hidden — they are the pages the two detail routes point at, and they have no template yet. And the page is still a **Draft**: everything you have built is visible to you and to no one else, which is where it stays until someone presses Publish.

And then the check that needs no tooling — the two tabs, same width, side by side. One of these is the prototype and one is served by Quilium out of a query, and the point of the whole tutorial is that you cannot tell which:

![The rendered home page and the HTML prototype side by side in two equal-width browser windows: the same featured piece, the same stamp, the same nav, indistinguishable.](/thumbs/4c62a8f0-aa3f-11f1-b9ed-29299a6d4676.png)

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b>The detail pages still 500 — that is expected.</b> Every piece on the home links to <code>/article/&lt;slug&gt;</code>, and that page-type has no template yet. A route that resolves to a missing template is a 500, not a 404: the link is right, the file is missing. It gets written two tutorials from here.</div></div>

## What you learned

- The layout is written once and every page inherits it.
- A block carries its queries; the template just iterates them, and `with:` is what turns a relation into something you can print.
- The dispatcher is what makes the content model extensible without touching the plumbing.
- Order that used to be a design decision becomes a property of the data the moment a query draws the list.

The structure behind all this is in [Build a template](/en/docs/build-a-template).

Next: [A static page](/en/docs/ai-static-page) — the easy one, on purpose.
