cms.builder
The builder adapter provides navigation helpers and asset management for Site Builder sites. Navigation functions automatically filter out draft pages and pages with nav set to false, and sort results by the sort field (ascending). Asset functions handle path resolution and cache busting.
Navigation
nav()
Get top-level navigation pages (pages with no parent).
{% set pages = cms.builder.nav() %}
{% for p in pages %}
<a href="{{ p.route }}">{{ p.title }}</a>
{% endfor %}
Returns a flat array of page objects from the configured pages collection, filtered to only include pages where:
draftisfalsenavistrue(or missing — defaults totruefor backwards compatibility)parentis empty
Custom Collection
Pass a collection ID to use a different pages collection:
{% set pages = cms.builder.nav('my-custom-pages') %}
Return Value
array — Each element is a page object with all indexed fields:
| Field | Type | Description |
|---|---|---|
id |
string | Page identifier |
title |
string | Page title |
route |
string | URL path (e.g., /about) |
template |
string | Template name from builder/pages/ |
layout |
string | Layout template name |
description |
string | Meta description |
draft |
boolean | Always false (drafts are filtered out) |
nav |
boolean | Always true (nav-hidden pages are filtered out) |
sort |
number | Sort order |
parent |
string | Parent page ID (always empty for nav() results) |
subnav()
Get child pages of a specific parent.
{% set children = cms.builder.subnav('blog') %}
{% for p in children %}
<a href="{{ p.route }}">{{ p.title }}</a>
{% endfor %}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
parentId |
string | yes | The id of the parent page |
collection |
string | no | Custom collection ID (defaults to configured pages collection) |
Example: Section Sub-Navigation
{# Main nav #}
<nav>
{% for p in cms.builder.nav() %}
<a href="{{ p.route }}">{{ p.title }}</a>
{% endfor %}
</nav>
{# Sub-nav for the current section #}
{% set children = cms.builder.subnav('services') %}
{% if children is not empty %}
<nav class="subnav">
{% for p in children %}
<a href="{{ p.route }}">{{ p.title }}</a>
{% endfor %}
</nav>
{% endif %}
navTree()
Get the full navigation hierarchy as a nested tree.
{% set tree = cms.builder.navTree() %}
Returns top-level pages with a children key containing their child pages, recursively nested.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
collection |
string | no | Custom collection ID (defaults to configured pages collection) |
Return Structure
Each page in the tree has all the standard page fields plus a children array:
[
{id: "home", title: "Home", route: "/", children: []},
{id: "services", title: "Services", route: "/services", children: [
{id: "web-design", title: "Web Design", route: "/services/web-design", children: []},
{id: "seo", title: "SEO", route: "/services/seo", children: []},
]},
{id: "about", title: "About", route: "/about", children: []},
]
Example: Two-Level Navigation
<nav>
{% for p in cms.builder.navTree() %}
<a href="{{ p.route }}">{{ p.title }}</a>
{% if p.children is not empty %}
<ul>
{% for child in p.children %}
<li><a href="{{ child.route }}">{{ child.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</nav>
Example: Recursive Navigation Macro
For deeply nested menus, use a Twig macro:
{% macro navItems(pages) %}
{% for p in pages %}
<li>
<a href="{{ p.route }}">{{ p.title }}</a>
{% if p.children is not empty %}
<ul>
{{ _self.navItems(p.children) }}
</ul>
{% endif %}
</li>
{% endfor %}
{% endmacro %}
<nav>
<ul>
{{ _self.navItems(cms.builder.navTree()) }}
</ul>
</nav>
Assets
asset()
Resolve an asset path to a URL with automatic cache busting.
{{ cms.builder.asset('images/hero.webp') }}
{# Output: /assets/images/hero.webp?v=1714300000 #}
Use this when you need the raw URL — for background images, srcset, custom attributes, or any case where css()/js() don't fit.
How Resolution Works
- Manifest exists — reads
manifest.jsonfrom the assets directory and resolves hashed filenames (e.g.,style.css→style.a1b2c3.css) - No manifest, file exists — appends
?v={mtime}for cache busting via file modification time - File not found — returns the raw path with no cache busting
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Asset path relative to the assets directory |
css()
Output a <link rel="stylesheet"> tag for a CSS file.
{{ cms.builder.css('style.css') }}
{{ cms.builder.css('vendor/normalize.css') }}
Output:
<link rel="stylesheet" href="/assets/style.css?v=1714300000">
<link rel="stylesheet" href="/assets/vendor/normalize.css?v=1714300000">
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | CSS file path relative to the assets directory |
js()
Output a <script> tag for a JavaScript file.
{{ cms.builder.js('app.js') }}
{{ cms.builder.js('app.js', {module: true}) }}
Output:
<script src="/assets/app.js?v=1714300000"></script>
<script type="module" src="/assets/app.js?v=1714300000"></script>
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | JS file path relative to the assets directory |
options |
object | no | Options: module (bool) adds type="module" attribute |
preload()
Output a <link rel="preload"> tag for preloading assets. Automatically adds the crossorigin attribute for fonts (required by browsers).
{{ cms.builder.preload('fonts/inter.woff2', 'font') }}
{{ cms.builder.preload('hero.webp', 'image') }}
{{ cms.builder.preload('app.js', 'script') }}
Output:
<link rel="preload" href="/assets/fonts/inter.woff2?v=1714300000" as="font" crossorigin>
<link rel="preload" href="/assets/hero.webp?v=1714300000" as="image">
<link rel="preload" href="/assets/app.js?v=1714300000" as="script">
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Asset path relative to the assets directory |
as |
string | yes | Resource type: font, image, script, style, fetch |
Asset Configuration
Assets Directory
By default, assets are served from the assets/ directory in your docroot. Configure a different path in Admin > Settings > Builder:
| Setting | Type | Default | Description |
|---|---|---|---|
| Assets Path | text | assets |
Public assets directory relative to docroot |
Your build tool should output files to this directory. The web server (Apache/Nginx) serves them as static files — T3 only generates the URLs.
Build Tool Manifest
For production builds with content-hashed filenames, output a manifest.json to your assets directory. The asset functions will automatically resolve hashed filenames from the manifest.
Vite
// vite.config.js
export default {
build: {
manifest: true,
outDir: 'assets'
}
}
esbuild
// build.js
require('esbuild').build({
entryPoints: ['src/app.js', 'src/style.css'],
outdir: 'assets',
metafile: true,
// Use a plugin to write manifest.json
})
When a manifest is present, hashed filenames are used instead of mtime query strings:
<!-- Without manifest -->
<link rel="stylesheet" href="/assets/style.css?v=1714300000">
<!-- With manifest -->
<link rel="stylesheet" href="/assets/style.a1b2c3.css">
Templates don't change between development and production — the asset functions handle resolution automatically.
Example: Complete Layout
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ page.title }}{% endblock %}</title>
{{ cms.builder.preload('fonts/inter.woff2', 'font') }}
{{ cms.builder.css('style.css') }}
</head>
<body>
{% include 'partials/nav.twig' %}
<main>{% block content %}{% endblock %}</main>
{% include 'partials/footer.twig' %}
{{ cms.builder.js('app.js', {module: true}) }}
</body>
</html>