Template Models
Coil templates render against a typed RenderModel, not an unstructured JSON blob.
If you are trying to understand where that model comes from, read Render pipeline and model composition alongside this page.
How A Route Actually Reaches A Template
The missing connection for most readers is usually not "what is a RenderModel?" but "where does
this model come from, and how does it get tied to a template?"
In Coil, those are two separate decisions:
- a route chooses a template name
- the runtime builds a
RenderModelfor that route execution
The final HTML render only happens after both of those are known.
Shoppr: official route -> template -> typed model
Shoppr's product-detail page is the clearest checked-in example.
The commerce module contributes the route surface and the page template name:
RouteSurface::new(
"commerce.product-detail",
RouteSurfaceKind::FrontendPage,
"/shop/products/{product_slug}",
)
.localized()
HttpSurfaceContribution::page(
"commerce.product-detail",
HttpSurfaceArea::Public,
"/shop/products/{product_slug}",
"commerce/product-detail",
)
.localized()
That means:
- the route name is
commerce.product-detail - the template name is
commerce/product-detail
When a request hits that route, the runtime renders it like this:
let selector = templates::template_selector(&page.template)?;
let model = self.render_model_for_execution(execution, &page.template, None)?;
Then the render-model builder adds the shared request keys first:
let mut model = RenderModel::new()
.with_value("customer_app", RenderValue::text(execution.customer_app.clone()))?
.with_value("route_name", RenderValue::text(execution.route.route_name.clone()))?
.with_value("locale", RenderValue::text(execution.locale.clone()))?
.with_object("site", site_model(self, execution)?)?
.with_object("links", links_model(self, execution)?)?
.with_object("page", page_model_for_route(execution, template_name, fragment_id))?;
After that, route-specific bindings add the product-specific fields:
"commerce.product-detail" => {
let slug = params
.get("product_slug")
.map(String::as_str)
.unwrap_or("harbor-cap");
if catalog.visible_product_for_site(site_id, slug).is_some() {
let product_cards = fixture.related_product_cards_for_product(slug);
model = model
.with_bool("has_product", true)?
.with_object("product", fixture.product_for(slug))?
.with_bool("has_product_cards", !product_cards.is_empty())?
.with_list("product_cards", product_cards)?;
}
}
Finally, the template consumes those exact keys:
<section class="product-page__hero" coil:if="${has_product}">
<h1 coil:text="${product.name}">Harbor Cap</h1>
<p class="product-page__price" coil:text="${product.price}">GBP 29</p>
<p coil:text="${product.summary}">Product summary</p>
</section>
This is the full binding story:
- route name chooses the response contract
- template name chooses the template file
- render-model shaping code decides which keys exist
- the template only reads those keys
Gitly: customer route -> template mapping
Gitly demonstrates a different part of the story.
Its customer app crate adds routes and page handlers directly:
for (route, template) in gitly_page_routes() {
let route_name = route.name.clone();
ensure_route(runtime, route)?;
ensure_handler(runtime, HandlerDefinition::page(route_name, template)?)?;
}
And the page-route table makes the template mapping explicit:
let pages = [
("home", "", "gitly/home"),
("explore", "/explore", "gitly/explore"),
("repo", "/forgeflow/platform-ui", "gitly/repository"),
("issues", "/forgeflow/platform-ui/issues", "gitly/issues"),
];
So for Gitly the template tie-in is clear:
- route
gitly.en.repo - handler
HandlerDefinition::page(...) - template
gitly/repository
Important current boundary
There are now two supported server-side model-shaping lanes:
- runtime-owned route bindings
- official modules shape the common storefront, account, admin, and operations models
- customer-owned render-model hooks
- linked Rust can mount new top-level namespaces and merge fields into shared objects such as
page
- linked Rust can mount new top-level namespaces and merge fields into shared objects such as
That means the answer to "where does my customer Rust build a custom page model and bind it to my custom template?" is now explicit:
- your app still chooses the route and template
- Coil builds the standard request model
- your linked Rust render-model hook contributes additional model data
- the template consumes the combined model
Read the full contract in Render Model Hooks.
Start With A Model And Its Template
Imagine runtime code shaping this model:
let model = RenderModel::new()
.with_value("locale", RenderValue::text("en-GB"))?
.with_object(
"site",
RenderModel::new()
.with_value("brand_name", RenderValue::text("Shoppr"))?
.with_value("display_name", RenderValue::text("Shoppr UK"))?,
)?
.with_bool("has_flash_messages", true)?
.with_list(
"flash_messages",
vec![
RenderModel::new()
.with_value("text", RenderValue::text("Order updated"))?
.with_value("level", RenderValue::text("info"))?,
],
)?
.with_asset_path("theme/assets/site.css", "https://cdn.example.com/theme/assets/site.abc123.css")?;
And this template consuming it:
<html xmlns:coil="https://coil.rs" coil:attr="lang=${locale}">
<head>
<link rel="stylesheet" coil:href="asset('theme/assets/site.css')" />
</head>
<body>
<h1 coil:text="${site.brand_name}">Brand</h1>
<section coil:if="${has_flash_messages}">
<article coil:each="message : ${flash_messages}">
<p coil:text="${message.text}">Fallback</p>
</article>
</section>
</body>
</html>
That is the core contract:
- runtime shapes typed values
- templates read those values declaratively
What templates do not do automatically:
- load CMS content instances by themselves
- turn content schema into
page.blocks - infer customer-owned request data because a field name appears in markup
- fetch live data for dynamic sections on their own
What Types Exist?
The core types are:
RenderModel- map of keys to values plus an asset-path map
RenderValue::TextRenderValue::TrustedHtmlRenderValue::BoolRenderValue::List(Vec<RenderModel>)RenderValue::Object(RenderModel)
This matters because templates are not dynamically evaluating arbitrary JSON. They are reading a small, typed value model.
That typed model is the request-time output of the render pipeline, not a direct dump of app manifest files or content schema definitions.
How Templates Consume The Model
These are the important rules:
${page.title}- reads nested object keys
coil:if="${has_flash_messages}"- expects a boolean
coil:each="entry : ${audit_entries}"- expects a list of child models
asset('theme/assets/site.css')- reads from the model’s asset-path map
If you need a branch such as “show this only for the French site,” shape a boolean or object in Rust
first when that keeps the page contract simpler. Coil now also supports narrow view-level comparisons
and block dispatch, so simple checks like ${site.locale == 'fr-FR'} and ${block.type == 'hero_section'}
are valid when the branch genuinely belongs in the template.
The Common Top-Level Request Model
Coil’s runtime request model usually starts with keys like:
customer_approute_namepathlocalemethodsiteroute_paramslinksnavigationpageflash_messages
That is why templates can usually stay simple: the runtime has already done the shaping work.
The important boundary is that Coil only renders what the request-time model exposes. If page,
product, or page.blocks are missing, the template does not go and compute them.
Lists, Nested Objects, And Booleans
Nested objects
Use objects when a group of values belongs together:
<span coil:text="${site.brand_name}">Brand</span>
<p coil:text="${page.summary}">Summary</p>
Runtime Block Dispatch In coil:each
When a list item exposes a type field, Coil now augments that item at render time so the template
can branch or dispatch on the block variant without extra customer-side shaping code.
Given this model:
let page = RenderModel::new().with_list(
"blocks",
vec![
RenderModel::new()
.with_value("type", RenderValue::text("hero_section"))?
.with_object(
"fields",
RenderModel::new()
.with_value("title", RenderValue::text("Hero title"))?,
)?,
],
)?;
this template:
<div coil:each="block : ${blocks}">
<section coil:if="${block.is_hero_section}" coil:text="${block.fields.title}">
Fallback
</section>
</div>
can read the derived block variant directly.
Inside the loop item, Coil adds:
block.is_<type>block.render_fragmentblock.render_fragment_shared
For a pages/home template and a block type of hero_section, those resolve to:
block.is_hero_section = trueblock.render_fragment = "pages/home/blocks/hero_section"block.render_fragment_shared = "blocks/hero_section"
That means the model reaching the template is richer than the raw list entry. Coil is shaping a render-oriented view of the block at render time.
That does not mean Coil automatically resolved the block from schema. It means the request-time model already included block-shaped data and the template runtime added render-oriented helpers on top.
Rendering Block Fragments By Type
Once a block loop item exposes render_fragment, the template can dispatch directly into a fragment:
<div coil:each="block : ${page.blocks}">
<coil:block coil:replace-fragment="${block.render_fragment}"></coil:block>
</div>
That is the preferred pattern for CMS-style page builders because it keeps each block variant in its
own fragment file instead of building one very large home.html.
Recommended fragment layout for a pages/home template:
templates/pages/home.htmltemplates/pages/home/blocks/hero_section.htmltemplates/pages/home/blocks/featured_events.htmltemplates/blocks/<type>.htmlfor shared fallbacks when multiple pages reuse the same block
Use coil:switch when you want the branching inline. Use render_fragment when the block wants its
own template.
Booleans
Use booleans for visibility and state:
<section coil:if="${has_flash_messages}">...</section>
<p coil:unless="${cart_items}">Your cart is empty.</p>
Lists
Lists are always lists of child models, not raw primitives:
<li coil:each="item : ${cart_items}">
<strong coil:text="${item.title}">Fallback</strong>
</li>
That keeps repeated structures explicit and typed.
Trusted HTML
TrustedHtml is the explicit escape hatch for pre-rendered markup.
Use it only when runtime code deliberately owns sanitization and structure. Everything else should stay as normal text.
Practical rule:
RenderValue::text(...)is normalRenderValue::trusted_html(...)is exceptional
Asset Paths In The Model
The asset helper works because RenderModel carries a separate asset-path map:
model = model.with_asset_path(
"theme/assets/site.css",
"https://cdn.example.com/theme/assets/site.abc123.css",
)?;
Then templates read it like this:
<link rel="stylesheet" coil:href="asset('theme/assets/site.css')" />
That is how templates stay readable while production still serves hashed assets.
Where Runtime Models Come From
For request rendering, the main shaping code lives in the runtime render layer.
The runtime currently:
- injects request-level keys such as
customer_app,route_name,path, andlocale - injects site and link objects
- injects published asset URLs from the active manifest
- adds route-specific data for storefront, account, admin, and other surfaces
- applies linked Rust render-model contributions before template render
That final step is the important customer handoff:
mount("crm_page", ...)- creates a new namespaced top-level object
merge("page", ..., MergePolicy::FailOnConflict)- extends the runtime-owned
pageobject without silently overwriting keys
- extends the runtime-owned
Example:
let overlay = RenderModel::new()
.with_value("render_source", RenderValue::text("linked-rust"))?;
let contributions = vec![
RenderModelContribution::merge("page", overlay, MergePolicy::FailOnConflict)?,
];
This is the practical reason template-model docs matter: if the model is well-shaped, templates stay small.
Customer Namespaces Vs Framework Namespaces
Coil already owns shared namespaces such as:
pagesitelinksnavigation
Customer code should generally mount its own namespaces for customer-owned request data:
crm_pagecampaignscustomer_extension
Merge into framework-owned objects only when the field genuinely belongs to a shared public contract. For that rule, read Render model hooks.
Dynamic Blocks And Live Sections
If a template renders a block list or a live section, remember that the template is still only rendering the final request-time values it was given.
Coil does not automatically turn:
- a block schema
- a page-builder declaration
- or a content model entry in
app.toml
into a fully resolved live block payload.
For that boundary, read Dynamic blocks and live-data sections and CMS page builder model.
What The Demos Prove Today
Use the demos for these two different lessons:
- Shoppr
- shows how a real server-side route ends up with strongly shaped keys such as
product,collection,cart_items,cart_summary, andpage
- shows how a real server-side route ends up with strongly shaped keys such as
- Gitly
- shows how a customer app adds its own routes and maps them to template names such as
gitly/repositoryandgitly/actions
- shows how a customer app adds its own routes and maps them to template names such as
- linked Rust render-model hooks
- show the supported customer-owned server-side handoff for mounting and merging page data before render
That distinction matters because different examples prove different parts of the contract. The official modules prove runtime-owned shaping, while render-model hooks are the customer-owned extension point.
Common Mistakes
Treating the model like untyped JSON
Coil’s model is intentionally typed. Use booleans, lists, objects, and trusted HTML for their real purposes.
Building asset URLs manually
Use the asset-path map through asset('...').
Pushing render-time decisions into templates
Shape booleans and nested objects in runtime code first.
Assuming customer page shaping is implicit
It is not implicit. Customer-owned server-side page shaping now goes through
RenderModelHooks::contribute_render_model(...).
Shoppr's strongest examples are runtime-shaped official-module routes. Gitly's strongest examples are customer-owned route and template registration. Read them that way.
Passing raw HTML through plain text fields
Use TrustedHtml only when the boundary is explicitly trusted.
Supporting Implementation And Repo Examples
Full implementation:
crates/coil-template/src/model/render.rscrates/coil-runtime/src/render/model.rscrates/coil-runtime/src/render/mod.rscrates/coil-commerce/src/module/platform/manifest.rsapps/shoppr/templates/admin/audit.htmlapps/shoppr/templates/commerce/product-detail.htmlapps/shoppr/templates/pages/home.htmlapps/gitly/crates/gitly-app/src/lib.rsapps/gitly/templates/gitly/home.htmlapps/gitly/templates/gitly/repository.html