Request And Render Lifecycle
Coil is an HTML-first framework. That sentence is easy to repeat and easy to misunderstand.
The important point is that request handling, auth, route resolution, render-model assembly, and progressive enhancement all belong to one coherent lifecycle.
What It Is
The request and render lifecycle is the path from an incoming HTTP request to:
- a full HTML page
- a fragment update
- a redirect after a form action
- a typed JSON response for a genuinely API-shaped route
Coil treats full pages as the default path, not as a thin fallback after API design.
Why It Exists
Many web stacks split the product in awkward ways:
- a browser app owns most state
- server rendering is optional or bolted on
- forms and redirects feel secondary
- auth and route semantics drift between page and API layers
Coil tries to keep those concerns unified, because real products usually mix:
- public pages
- account surfaces
- admin pages
- stateful form actions
- localised routes
The Short Version
The exact internals are deeper than this page, but the shape is consistent:
- the runtime resolves the request against host, site, locale, and route surfaces
- auth and capability checks run against the resolved route
- request input is normalized and validated
- handlers execute page, action, or API behaviour
- a render model is assembled for page-shaped responses
- templates render HTML using explicit data rather than arbitrary code execution
That lifecycle is what lets Coil keep HTML-first rendering without giving up operational or security discipline.
Shoppr Home Page Example
Take a request for:
GET /en-GB/pages/home
Host: www.example.com
In Shoppr, the runtime will effectively do this:
- Resolve the host to the correct site.
- In Shoppr,
www.example.commaps to the UK site.
- In Shoppr,
- Resolve the locale.
/en-GB/...selects English (Great Britain).
- Match the route surface.
- The CMS page route resolves the
homepage.
- The CMS page route resolves the
- Evaluate auth and visibility.
- Public page reads are allowed without customer login.
- Build the base render model.
- Site, locale, page content, navigation, storefront context, and SEO metadata are assembled.
- Apply customer render-model contributions.
- Linked Rust can mount namespaced model data and merge fields into shared objects such as
page.
- Linked Rust can mount namespaced model data and merge fields into shared objects such as
- Execute render hooks.
- Runtime-installed extensions can contribute to the render path.
- Render the template.
- The template engine turns the render model into the final HTML response.
This is why the page route is not "just a template file". Host, site, locale, SEO, CMS content, auth, and extensions all participate before the final HTML is rendered.
Shoppr Product Page Example
Now take a product detail page:
GET /en-GB/shop/products/harbor-cap
Host: www.example.com
The flow is similar, but the render model carries commerce-specific state:
- selected site
- selected locale
- canonical and alternate URLs
- product card and detail data
- collection and related-product context
- structured product SEO metadata
- any extension or linked-backend contributions relevant to rendering
That model is prepared in Rust first, and only then rendered through the template engine. This is why the template language can stay intentionally constrained.
Here is the concrete binding path the docs should make explicit.
The commerce module contributes the page surface and template:
HttpSurfaceContribution::page(
"commerce.product-detail",
HttpSurfaceArea::Public,
"/shop/products/{product_slug}",
"commerce/product-detail",
)
.localized()
The runtime then assembles the common request model and appends route-specific keys for that route:
let mut model = RenderModel::new()
.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))?;
// later for commerce.product-detail
model = model
.with_bool("has_product", true)?
.with_object("product", fixture.product_for(slug))?
.with_list("product_cards", product_cards)?;
And the product-detail 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>
<a class="button" coil:attr="href=${links.cart}">Review cart</a>
</section>
Linked Rust can now extend that model before final render through RenderModelHooks, for example by:
- mounting a customer namespace such as
crm_page - merging extra fields into
page
This is the actual lifecycle contract:
- the route picks the template name
- the runtime shapes the base render model
- linked Rust can contribute mount and merge operations
- the template reads only the final shaped keys
The template is not discovering data on its own. The runtime has already decided the page contract.
Gitly Custom Route Example
Gitly demonstrates the customer-owned side of the same story.
Its app crate adds routes and maps them to template names 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)?)?;
}
With route definitions like:
("repo", "/forgeflow/platform-ui", "gitly/repository")
So the customer-owned part is explicit:
- Gitly defines the route
- Gitly defines the template name
- the runtime still performs the render step
Important distinction:
- Gitly still demonstrates customer-owned route-to-template mapping clearly
- customer-owned server-side page shaping is now done through linked Rust render-model hooks, not by an implicit or magical template-side data source
That is why route ownership and model ownership should be thought of as separate concerns.
Stateful Action Example: Cart Update
Now look at a state-changing request:
POST /cart
Host: www.example.com
This is still part of the same lifecycle. The runtime:
- resolves host, site, locale, and route
- resolves the browser session
- validates CSRF
- normalizes posted cart inputs
- executes the cart mutation
- updates runtime state
- returns the correct HTML-first outcome
- usually a redirect
- sometimes a fragment response for progressive enhancement
This is what “HTML-first” means in Coil in practice. Forms and redirects are not legacy escape hatches. They are part of the primary model.
Where Linked Rust And WASM Participate
The lifecycle is also where customization enters the runtime.
Linked customer Rust can participate by:
- shaping customer-specific business rules
- contributing to checkout or order behaviour
- handling verified webhook logic
- recording customer-specific audit evidence
Runtime-installed WASM can participate by:
- render hooks
- admin widgets
- bounded runtime extension points
That is why lifecycle understanding matters. It tells you where customization belongs.
What "HTML-First" Means In Practice
It means:
- forms and redirects are normal
- server-rendered pages are normal
- fragments are supported for progressive enhancement
- JSON exists when the route is truly API-shaped
It does not mean:
- no interactivity
- no JavaScript
- no typed backend behaviour
Common Mistakes
Thinking of rendering as a late presentation step
Site, locale, auth, and module composition all affect rendering. It is not just "turn data into HTML."
Expecting arbitrary logic in templates
Coil keeps templates deliberately constrained. Complex state should be prepared in Rust render models, not improvised inside the template engine.
Assuming a template file alone defines the page contract
The page contract actually comes from both:
- the handler choosing the template name
- the runtime shaping the
RenderModel
If either side is missing, the template will feel disconnected.
Treating form actions as second-class behaviour
In Coil, stateful form flows are part of the primary model, especially for storefronts, account areas, and admin surfaces.
Forgetting that host and locale resolution happen before rendering
If you think only in terms of route path strings, multi-site behaviour will feel confusing quickly.