---
title: Routing and URLs
description: 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.
updated: 2026-06-22
---

## 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](/en/docs/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.

<div class="q-callout q-callout-note"><i class="fa-solid fa-circle-info lead"></i>
  <div><b>One page-type, many pages.</b> 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.</div></div>

## The four objects in play

Routing ties together four objects you already know from [The content model](/en/docs/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.

<div class="q-ph"><i class="fa-regular fa-image"></i><span><b>Placeholder:</b> 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.</span></div>

## 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:

```yaml
# 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:

```yaml
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](/en/docs/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:

```yaml
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.

<div class="q-callout q-callout-warn"><i class="fa-solid fa-triangle-exclamation lead"></i>
  <div><b><code>route.*</code> is query-only.</b> The <code>route.&lt;collection&gt;.slug</code> variable exists only while the page-type resolves a query. It does <b>not</b> exist in a template. Reach for it in a template and you get nothing.</div></div>

## 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:

```liquid
{% 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](/en/docs/how-quilium-builds).

## 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:

```liquid
<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](/en/docs/blog), the how-to that wires a route, page-type query and template together.
- [The content model](/en/docs/content-model), the page-type, content-type and collection objects routing depends on.
- [Multilingual model](/en/docs/multilingual-model), why and how generated URLs are locale-aware.
