Skip to content
Quilium Docs
Back to site
Explanation

Routing and URLs

Understand how Quilium turns a request into a page, so you can reason about every URL your site serves instead of guessing why one returns a 404.

What this page is about

Every URL on a Quilium site is the meeting point of three things: a page you created, the page-type that gives it its shape, and, for collection items, a route that maps a URL pattern onto an collection. This page explains how those pieces fit together and how Quilium resolves an incoming request into rendered HTML.

It is the mental model, not a recipe. When you want the step-by-step version, follow Build a blog.

Two kinds of URL

A Quilium site serves two kinds of URL, and they resolve along different paths.

Kind Backed by Example How it resolves
Static page A page instance of a page-type /about, /newsletter Direct lookup by the page's own address. One page, one URL.
Detail URL A route that maps a pattern onto a collection /the-spring-issue Pattern match extracts a slug; a query on the page-type fetches the matching entry. One route, many URLs.

A static page is the simple case: you created the page, it lives at an address, the request finds it. A detail URL is the interesting case, because one page-type and one route stand in for as many URLs as there are entries in the collection.

One page-type, many pages. A detail page-type is rendered once per collection entry. There is no "detail" keyword in Quilium, it is a normal page-type whose job happens to be rendering a single collection item.

The four objects in play

Routing ties together four objects you already know from The content model. Telling apart who holds what is what keeps URLs predictable.

Object Its part in routing
route The URL pattern. Names the collection segment and the slug it captures.
page-type Carries the query that turns a captured slug into data. The detail page-type is the one that does this.
collection The collection being addressed. Each entry owns a unique slug.
slug The stable, URL-safe identifier of an entry. The hinge the whole detail URL turns on.

The relationship reads in one direction: a route captures a slug, the detail page-type runs a query against an collection filtered by that slug, and the matching entry becomes the page's data.

Placeholder: URL-resolution diagram. Two lanes side by side: (1) static page → direct address lookup → render; (2) detail URL → route pattern match → captured slug → page-type query (where slug = captured) → collection entry → render. Annotate the resolution order (match route, then query, then render) and mark where `route.*` lives (query only) versus where the template reads `page.custom.*`. To produce from the fil-rouge demo site.

How a detail URL resolves, step by step

When a request comes in for a detail URL, Quilium resolves it in a fixed order:

  1. Match the route. Quilium compares the path against each route pattern. The matching route's pattern captures the variable segment as a named value, the slug.
  2. Run the page-type query. The route points at a page of a given page-type. That page-type declares a query that reads the captured slug through route.<collection>.slug and fetches the one collection entry whose slug matches.
  3. Render the template. The query result lands in page.custom.<queryKey> as an array. The template reads it from there and renders the page.

If step 1 finds no route, the request falls through to a 404. If step 2 finds no entry, the page renders empty (guard for it in the template). The order matters: the route exists to feed the query, and the query exists to feed the template.

Route patterns and the leading-slash rule

A route's path holds only the variable part of the URL. Quilium appends it verbatim to the URL of the page the route points at, and inserts nothing in between. A page URL never ends with a slash, so the path of a detail route starts with one. A collection mounted on a "Journal" page at /journal needs a route path of a slash plus the slug segment:

# GOOD: a leading slash, then the variable part; the page supplies /journal
name: article
path: '/{{article.slug}}'

# BAD: without the slash the path glues to the page URL
path: '{{article.slug}}'

The GOOD form gives /journal/the-spring-issue. The BAD form gives /journalthe-spring-issue, which matches nothing and returns a 404. The same rule settles the other end: a route on a fixed page has an empty path, not /, because / would append a trailing slash to an address that has none.

Patterns can capture more than one segment when the entry has the fields to fill them, for example a category plus a slug:

name: article
path: '/{{article.category}}/{{article.slug}}'

Each {{...}} segment is a wildcard filled from the entry's own data and captured back out of the URL on the way in. The collection name in the pattern (article here) is the handle the query reads as route.article.slug.

The page side of the URL follows the page tree. A child page always contributes its own slug: a listing page at /work with a child view lives at /work/view, and a route mounted on that child serves /work/view/<slug>. A child cannot take its parent's URL: an empty slug, or two pages resolving to the same address, is refused at publish with 422 UniqueUrl. To keep a short detail URL next to a listing page, uncheck the child's slug prefix (isSlugPrefixed: false). The page stays a child in the back-office, but its URL is built from its own slug alone, so a listing articles with an unprefixed child article serves /article/<slug>. Build a blog shows both shapes.

The query that reads the slug

The detail page-type declares a query field that fetches the entry by its captured slug. This is where, and the only where, route.* is valid:

article:
  type: query
  label: Article
  config:
    itemSet: articles
    where:
      '&=':
        slug: '{{route.article.slug}}'
    limit: 1

Read it as: from the articles collection, take the one entry whose slug equals the slug captured by the route. The '&=' strict operator keeps the clause in place even when route.article.slug resolves to empty, so the query never silently matches everything. The limit: 1 reflects that a detail page is about exactly one entry.

route.* is query-only. The route.<collection>.slug variable exists only while the page-type resolves a query. It does not exist in a template. Reach for it in a template and you get nothing.

Why the template reads page.custom, not route

This is the part that surprises people, so it is worth stating the design rationale plainly. The route captures the slug; the query consumes it; the template never sees the slug variable at all. By the time the template runs, the work the slug was for is already done, the entry has been fetched, and it sits in page.custom.

So the template reads the resolved entry, not the raw URL:

{% if page.custom.article and page.custom.article.size > 0 %}
  {% assign a = page.custom.article[0] %}
  <h1>{{ a.title }}</h1>
  <div class="prose">{{ a.body }}</div>
{% endif %}

The query key (article) becomes the page.custom key, and the result is always an array, even when the query is limited to one. Guard with and ....size > 0 before indexing [0]. This separation is deliberate: the page-type owns data fetching (where route.* is in scope), the template owns presentation (where only resolved data is in scope). Keeping the two apart is what lets the same template render every entry without ever touching URL parsing. For the wider picture, see How Quilium renders the site.

Generating URLs with the | url filter

The inverse problem, building a link to a detail page, is the job of the | url filter. You name the route and hand it the full entry object, and Quilium produces the locale-aware URL:

<a href="{{ 'article' | url: article: a }}">{{ a.title }}</a>

Here 'article' is the route name, article: is the route parameter key, and a is the entry. Quilium reads the entry's own slug (and any other segments the pattern needs, like category) straight off the object, so you never assemble the path by hand.

This matters for three reasons. The filter extracts the slug for you, so a renamed slug never strands a hardcoded link. It fills multi-segment patterns from the same object. And the output is locale-aware, so a translated entry links to its translated URL. Building the href yourself with string concatenation throws all three away.

You pass What happens
The full entry object (a) Quilium reads slug and any other pattern segments off it.
The wrong parameter key The URL comes back empty (?article=); the key must match the route's collection name.
A null entry The URL resolves to /undefined; guard the upstream query first.

How it all fits

Put the pieces in a line and the model is simple:

  • A route pattern, appended verbatim to its page URL, captures a slug from the URL.
  • The detail page-type runs a query that reads route.<collection>.slug and pulls the one matching collection entry.
  • The template reads the resolved entry from page.custom.<queryKey>, never route.*.
  • To link back the other way, the | url filter takes the full entry and rebuilds the URL.

Get that flow right and routing stops being mysterious: a URL is just a slug on its way to a query, and a link is just an entry on its way back to a URL.

Where to go next

  • Build a blog, the how-to that wires a route, page-type query and template together.
  • The content model, the page-type, content-type and collection objects routing depends on.
  • Multilingual model, why and how generated URLs are locale-aware.

Last updated September 7, 2026.