Skip to content

Twig Builder Reference Since 3.5.0

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 order results by the page order file (tcms-data/{collection}/.order.json). Asset functions handle path resolution and cache busting.

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:

  • draft is false
  • nav is true (or missing — defaults to true for backwards compatibility)
  • the page is a top-level page in the order file (.order.json — no parent)

Pass a collection ID to use a different pages collection:

{% set pages = cms.builder.nav('my-custom-pages') %}

array — Each element is a page object with all indexed fields:

FieldTypeDescription
idstringPage identifier
titlestringPage title
routestringURL path (e.g., /about)
templatestringTemplate name from builder/pages/
draftbooleanAlways false (drafts are filtered out)
navbooleanAlways true (nav-hidden pages are filtered out)
statusnumberHTTP status code
sitemapbooleanWhether the page is included in the sitemap
changeFrequencystringSitemap change-frequency hint
prioritynumberSitemap priority
updatedstringLast modified date
createdstringCreation date

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 %}
ParameterTypeRequiredDescription
parentIdstringyesThe id of the parent page
collectionstringnoCustom collection ID (defaults to configured pages collection)
{# 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 %}

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.

ParameterTypeRequiredDescription
collectionstringnoCustom collection ID (defaults to configured pages collection)

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: []},
]
<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>

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>

Resolve a page’s URL by id, filling any dynamic {param} placeholders. Use it instead of hard-coding routes — rename /blog/{id} to /posts/{id} later and every link rebuilds itself.

{# Static page #}
<a href="{{ cms.builder.url('about') }}">About</a>
{# Dynamic route — params fill the placeholders #}
<a href="{{ cms.builder.url('blog-post', { id: post.id }) }}">{{ post.title }}</a>
ParameterTypeRequiredDescription
pageIdstringyesThe id of the page
paramsarraynoValues for {param} placeholders in the route
collectionstringnoCustom collection ID (defaults to configured pages collection)
  • Returns the route prefixed with cms.api (the site’s base URL) — a relative path.
  • Missing pages, and pages with no route, return an empty string.
  • Unfilled placeholders are left in place (e.g. /blog/{id}) so a broken reference is visible at render time rather than at click time.
  • Param values are URL-encoded automatically.

The absolute form of url(), for canonical tags, og:url and redirects — anywhere a relative path will not do.

<link rel="canonical" href="{{ cms.builder.canonicalUrl(page) }}">
<meta property="og:url" content="{{ cms.builder.canonicalUrl(page) }}">

Takes a page id or a whole page array, so a layout can pass the page it already has instead of digging out an id:

{{ cms.builder.canonicalUrl('pricing') }}
{{ cms.builder.canonicalUrl('blog-post', { id: post.id }) }}
ParameterTypeRequiredDescription
pageOrIdstring|arrayyesPage id, or a page array carrying one
paramsarraynoValues for {param} placeholders in the route
collectionstringnoCustom collection ID (defaults to configured pages collection)
  • Scheme and domain come from the site config, so it stays correct on local and staging instead of hard-coding production into a template.
  • Everything url() does — base prefix, param filling, encoding — applies unchanged.
  • Returns an empty string wherever url() does. A missing or unrouted page never degrades to a bare domain, which would look like a working link to the homepage.

Linking to a collection object rather than a page? Use cms.collection.canonicalObjectUrl(), which does the same job for objects.

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.

  1. Manifest exists — reads manifest.json from the assets directory and resolves hashed filenames (e.g., style.cssstyle.a1b2c3.css)
  2. No manifest, file exists — appends ?v={mtime} for cache busting via file modification time
  3. File not found — returns the raw path with no cache busting
ParameterTypeRequiredDescription
pathstringyesAsset path relative to the assets directory

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">
ParameterTypeRequiredDescription
pathstringyesCSS file path relative to the assets directory

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>
ParameterTypeRequiredDescription
pathstringyesJS file path relative to the assets directory
optionsobjectnoOptions: module (bool) adds type="module" attribute

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">
ParameterTypeRequiredDescription
pathstringyesAsset path relative to the assets directory
asstringyesResource type: font, image, script, style, fetch

By default, assets are served from the assets/ directory in your docroot. Configure a different path in Admin > Settings > Builder:

SettingTypeDefaultDescription
Assets PathtextassetsPublic 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.

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.config.js
export default {
build: {
manifest: true,
outDir: 'assets'
}
}
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.

<!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>
{# Total CMS core assets. These are not cms.builder.* functions -- they emit
T3's own CSS/JS (grid, galleries, pagination, htmx) plus any extension
assets. See docs/site-builder/frontend. #}
{{ cms.assetsHead() }}
{{ 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.assetsBody() }}
{{ cms.builder.js('app.js', {module: true}) }}
</body>
</html>