Chapter 4: Templates
1. Why Templates
In Chapter 1, you saw res.html("products.html", data) produce a full HTML page. That rendering was done by Frond, Tina4's built-in template engine. Zero dependencies. Twig-compatible. Built from scratch. If you know Twig, Jinja2, or Nunjucks, you know 90% of Frond.
Templates live in src/templates/. Call res.html("page.html", data) and Frond loads src/templates/page.html, processes the tags and expressions, and returns the final HTML.
This chapter covers every feature of the template engine. After this, you build real pages.
2. Variables and Expressions
Output a variable with double curly braces:
<h1>Hello, {{ name }}!</h1>Route handler:
import { Router } from "tina4-nodejs";
Router.get("/welcome", async (req, res) => {
return res.html("welcome.html", {
name: "Alice"
});
});Create src/templates/welcome.html:
<!DOCTYPE html>
<html>
<head><title>Welcome</title></head>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>Expected browser output:
Hello, Alice!Accessing Nested Data
Dot notation reaches into nested objects:
const data = {
user: {
name: "Alice",
email: "alice@example.com",
address: {
city: "Cape Town",
country: "South Africa"
}
}
};
return res.html("profile.html", data);<p>{{ user.name }} lives in {{ user.address.city }}, {{ user.address.country }}.</p>Output:
Alice lives in Cape Town, South Africa.Expressions
Basic expressions work inside {{ }}:
<p>Total: ${{ price * quantity }}</p>
<p>Discounted: ${{ price * 0.9 }}</p>
<p>Full name: {{ first_name ~ " " ~ last_name }}</p>The ~ operator concatenates strings.
3. Template Inheritance
Template inheritance is the engine's most powerful feature. Define a base layout once. Extend it in every page.
Base Layout
Create src/templates/base.twig:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My App{% endblock %}</title>
<link rel="stylesheet" href="/css/tina4.css">
{% block head %}{% endblock %}
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2026 My App. All rights reserved.</p>
</footer>
<script src="/js/frond.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>Child Template
Create src/templates/about.twig:
{% extends "base.twig" %}
{% block title %}About Us{% endblock %}
{% block content %}
<h1>About Us</h1>
<p>We have been building things since {{ founded_year }}.</p>
<p>Our team has {{ team_size }} members across {{ office_count }} offices.</p>
{% endblock %}Route handler:
import { Router } from "tina4-nodejs";
Router.get("/about", async (req, res) => {
return res.html("about.twig", {
founded_year: 2020,
team_size: 12,
office_count: 3
});
});Using {{ parent() }}
Add to a block rather than replace it:
{% extends "base.twig" %}
{% block head %}
{{ parent() }}
<link rel="stylesheet" href="/css/contact-form.css">
{% endblock %}
{% block content %}
<h1>Contact Us</h1>
<form>...</form>
{% endblock %}4. Includes
Break templates into reusable pieces with {% include %}:
Create src/templates/partials/header.twig:
<header>
<div class="logo">{{ site_name | default("My App") }}</div>
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/contact">Contact</a>
</nav>
</header>Create src/templates/partials/product-card.twig:
<div class="product-card{{ product.featured ? ' featured' : '' }}">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price | number_format(2) }}</p>
{% if product.inStock %}
<span class="badge-success">In Stock</span>
{% else %}
<span class="badge-danger">Out of Stock</span>
{% endif %}
</div>Use them in a page:
{% extends "base.twig" %}
{% block content %}
{% include "partials/header.twig" %}
<h1>Products</h1>
{% for product in products %}
{% include "partials/product-card.twig" %}
{% endfor %}
{% endblock %}Passing Variables to Includes
{% include "partials/header.twig" with {"site_name": "Cool Store"} %}Use only to isolate the included template from the parent scope:
{% include "partials/header.twig" with {"site_name": "Cool Store"} only %}5. For Loops
Loop through arrays with {% for %}:
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>The loop Variable
Inside a for loop, Frond provides a special loop variable:
| Property | Type | Description |
|---|---|---|
loop.index | int | Current iteration (1-based) |
loop.index0 | int | Current iteration (0-based) |
loop.first | bool | True on the first iteration |
loop.last | bool | True on the last iteration |
loop.length | int | Total number of items |
loop.revindex | int | Iterations remaining (1-based) |
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Price</th></tr>
</thead>
<tbody>
{% for product in products %}
<tr class="{{ loop.index is odd ? 'row-light' : 'row-dark' }}">
<td>{{ loop.index }}</td>
<td>{{ product.name }}</td>
<td>${{ product.price | number_format(2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>Empty Lists
Handle empty lists with {% else %}:
{% for product in products %}
<div class="product-card">
<h3>{{ product.name }}</h3>
</div>
{% else %}
<p>No products found.</p>
{% endfor %}Looping Over Key-Value Pairs
{% for key, value in metadata %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}6. Conditionals
if / elseif / else
{% if user.role == "admin" %}
<a href="/admin">Admin Panel</a>
{% elseif user.role == "editor" %}
<a href="/editor">Editor Dashboard</a>
{% else %}
<a href="/profile">My Profile</a>
{% endif %}Ternary Operator
<span class="{{ is_active ? 'text-green' : 'text-gray' }}">
{{ is_active ? 'Active' : 'Inactive' }}
</span>Testing for Existence
{% if error_message is defined %}
<div class="alert alert-danger">{{ error_message }}</div>
{% endif %}7. Filters
Filters transform values. Apply them with the | (pipe) character.
Text Filters
| Filter | Input | Output | Description |
|---|---|---|---|
upper | "hello" | "HELLO" | Uppercase |
lower | "HELLO" | "hello" | Lowercase |
capitalize | "hello world" | "Hello world" | Capitalize first letter |
title | "hello world" | "Hello World" | Capitalize each word |
trim | " hello " | "hello" | Remove whitespace |
striptags | "<b>bold</b>" | "bold" | Remove HTML tags |
Number Filters
| Filter | Input | Output | Description |
|---|---|---|---|
number_format(2) | 1234.5 | "1,234.50" | Format number |
round | 3.7 | 4 | Round to nearest integer |
abs | -5 | 5 | Absolute value |
Array Filters
| Filter | Input | Output | Description |
|---|---|---|---|
length | [1,2,3] | 3 | Count items |
join(", ") | ["a","b","c"] | "a, b, c" | Join with separator |
first | [1,2,3] | 1 | First item |
last | [1,2,3] | 3 | Last item |
reverse | [1,2,3] | [3,2,1] | Reverse order |
sort | [3,1,2] | [1,2,3] | Sort ascending |
The default Filter
<p>{{ subtitle | default("No subtitle") }}</p>
<p>{{ user.nickname | default(user.name) | default("Anonymous") }}</p>The escape and raw Filters
All {{ }} output is auto-escaped for HTML safety. If you trust the content and need raw HTML:
{{ trusted_html | raw }}Use raw with caution. Apply it only to content you control. Never to user input.
Chaining Filters
{{ name | trim | lower | capitalize }}
{# " ALICE SMITH " -> "Alice smith" #}8. Macros
Macros are reusable template functions. Define once. Call many times.
Defining a Macro
Create src/templates/macros.twig:
{% macro button(text, url, style) %}
<a href="{{ url | default('#') }}" class="btn btn-{{ style | default('primary') }}">
{{ text }}
</a>
{% endmacro %}
{% macro alert(message, type) %}
<div class="alert alert-{{ type | default('info') }}">
{{ message }}
</div>
{% endmacro %}
{% macro input(name, label, type, value) %}
<div class="form-group">
<label for="{{ name }}">{{ label | default(name | capitalize) }}</label>
<input type="{{ type | default('text') }}" id="{{ name }}" name="{{ name }}" value="{{ value | default('') }}">
</div>
{% endmacro %}Using Macros
{% from "macros.twig" import button, alert, input %}
{% extends "base.twig" %}
{% block content %}
{{ alert("Your profile has been updated.", "success") }}
<form method="POST" action="/profile">
{{ input("name", "Full Name", "text", user.name) }}
{{ input("email", "Email Address", "email", user.email) }}
{{ button("Save Changes", "", "primary") }}
{{ button("Cancel", "/dashboard", "secondary") }}
</form>
{% endblock %}9. Special Tags
{% raw %} -- Literal Output
When you need to output literal {{ }} (for a Vue.js template, for example):
{% raw %}
<div id="app">
{{ message }}
</div>
{% endraw %}Comments
{# This comment will not appear in the HTML output #}10. Template Route Export Pattern
Tina4 Node.js has a special pattern for file-based routes with templates. Export a template constant and return data from the handler:
Create src/routes/catalog/get.ts:
export const template = "catalog.twig";
export default async (req, res) => {
const category = req.query.category ?? "";
const products = [
{ name: "Espresso Machine", category: "Kitchen", price: 299.99, featured: true },
{ name: "Yoga Mat", category: "Fitness", price: 29.99, featured: false },
{ name: "Standing Desk", category: "Office", price: 549.99, featured: true }
];
const filtered = category
? products.filter(p => p.category.toLowerCase() === category.toLowerCase())
: products;
return {
products: filtered,
active_category: category,
categories: [...new Set(products.map(p => p.category))]
};
};Tina4 renders src/templates/catalog.twig with the returned data. The route handler stays clean -- it returns data, the framework handles rendering.
11. Exercise: Build a Product Catalog Page
Build a product catalog page with a base layout, product cards, category filters, and a reusable card macro.
Requirements
- Create a base layout at
src/templates/catalog-base.twigwith blocks fortitle,content, andscripts - Create a macro file at
src/templates/catalog-macros.twigwith aproductCard(product)macro and acategoryFilter(categories, active)macro - Create a page template at
src/templates/catalog.twigthat extends the base, uses the macros, shows category filter buttons, and shows product cards in a grid - Create a route at
GET /catalogthat accepts an optional?category=filter
Data
const products = [
{ name: "Espresso Machine", category: "Kitchen", price: 299.99, inStock: true, featured: true },
{ name: "Yoga Mat", category: "Fitness", price: 29.99, inStock: true, featured: false },
{ name: "Standing Desk", category: "Office", price: 549.99, inStock: true, featured: true },
{ name: "Blender", category: "Kitchen", price: 89.99, inStock: false, featured: false },
{ name: "Running Shoes", category: "Fitness", price: 119.99, inStock: true, featured: false },
{ name: "Desk Lamp", category: "Office", price: 39.99, inStock: true, featured: true },
{ name: "Cast Iron Skillet", category: "Kitchen", price: 44.99, inStock: true, featured: false }
];12. Solution
Create src/templates/catalog-base.twig:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Product Catalog{% endblock %}</title>
<link rel="stylesheet" href="/css/tina4.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; background: #f8f9fa; }
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 20px; margin-bottom: 24px; }
.header h1 { margin: 0; }
.filters { margin-bottom: 20px; }
.filter-btn { display: inline-block; padding: 6px 14px; margin: 0 6px 6px 0; border-radius: 20px; text-decoration: none; font-size: 0.9em; border: 1px solid #dee2e6; color: #495057; background: white; }
.filter-btn.active { background: #2c3e50; color: white; border-color: #2c3e50; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.product-card { background: white; border: 2px solid #e9ecef; border-radius: 8px; padding: 16px; }
.product-card.featured { border-color: #f39c12; background: #fef9e7; }
.product-name { font-size: 1.1em; font-weight: 600; margin: 0 0 4px; }
.product-category { font-size: 0.85em; color: #6c757d; }
.product-price { font-size: 1.2em; font-weight: bold; color: #27ae60; }
.badge-featured { background: #f39c12; color: white; display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75em; margin-left: 8px; }
.badge-stock { background: #d4edda; color: #155724; display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75em; margin-left: 8px; }
.badge-nostock { background: #f8d7da; color: #721c24; display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75em; margin-left: 8px; }
.empty-state { text-align: center; padding: 40px; color: #6c757d; }
</style>
</head>
<body>
{% block content %}{% endblock %}
<script src="/js/frond.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>Create src/templates/catalog-macros.twig:
{% macro productCard(product) %}
<div class="product-card{{ product.featured ? ' featured' : '' }}">
<p class="product-name">
{{ product.name }}
{% if product.featured %}
<span class="badge-featured">Featured</span>
{% endif %}
</p>
<p class="product-category">{{ product.category }}</p>
<p class="product-price">
${{ product.price | number_format(2) }}
{% if product.inStock %}
<span class="badge-stock">In Stock</span>
{% else %}
<span class="badge-nostock">Out of Stock</span>
{% endif %}
</p>
</div>
{% endmacro %}
{% macro categoryFilter(categories, active) %}
<div class="filters">
<a href="/catalog" class="filter-btn{{ active is not defined or active == '' ? ' active' : '' }}">All</a>
{% for cat in categories %}
<a href="/catalog?category={{ cat }}" class="filter-btn{{ active == cat ? ' active' : '' }}">{{ cat }}</a>
{% endfor %}
</div>
{% endmacro %}Create src/templates/catalog.twig:
{% extends "catalog-base.twig" %}
{% from "catalog-macros.twig" import productCard, categoryFilter %}
{% block title %}{{ active_category | default("All") }} Products - Catalog{% endblock %}
{% block content %}
<div class="header">
<h1>Product Catalog</h1>
<p>{{ products | length }} product{{ products | length != 1 ? 's' : '' }}{% if active_category %} in {{ active_category }}{% endif %}</p>
</div>
<div class="container">
{{ categoryFilter(categories, active_category) }}
{% if products | length > 0 %}
<div class="product-grid">
{% for product in products %}
{{ productCard(product) }}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h2>No products found</h2>
<p>Try a different category or <a href="/catalog">view all products</a>.</p>
</div>
{% endif %}
</div>
{% endblock %}Create src/routes/catalog.ts:
import { Router } from "tina4-nodejs";
Router.get("/catalog", async (req, res) => {
const allProducts = [
{ name: "Espresso Machine", category: "Kitchen", price: 299.99, inStock: true, featured: true },
{ name: "Yoga Mat", category: "Fitness", price: 29.99, inStock: true, featured: false },
{ name: "Standing Desk", category: "Office", price: 549.99, inStock: true, featured: true },
{ name: "Blender", category: "Kitchen", price: 89.99, inStock: false, featured: false },
{ name: "Running Shoes", category: "Fitness", price: 119.99, inStock: true, featured: false },
{ name: "Desk Lamp", category: "Office", price: 39.99, inStock: true, featured: true },
{ name: "Cast Iron Skillet", category: "Kitchen", price: 44.99, inStock: true, featured: false }
];
const categories = [...new Set(allProducts.map(p => p.category))].sort();
const activeCategory = req.query.category ?? "";
const products = activeCategory
? allProducts.filter(p => p.category.toLowerCase() === String(activeCategory).toLowerCase())
: allProducts;
return res.html("catalog.twig", {
products,
categories,
active_category: activeCategory
});
});Expected browser output for /catalog:
- A dark header with "Product Catalog" and "7 products"
- Filter buttons: All (active), Fitness, Kitchen, Office
- A grid of 7 product cards
- Three cards (Espresso Machine, Standing Desk, Desk Lamp) have a gold border and "Featured" badge
Expected browser output for /catalog?category=Kitchen:
- Header shows "3 products in Kitchen"
- The Kitchen filter button is active
- Three cards: Espresso Machine, Blender, Cast Iron Skillet
13. Gotchas
1. {% extends %} Must Be the First Tag
Problem: Template inheritance does not work. The page renders without the base layout.
Cause: {% extends "base.twig" %} must be the first tag in the template. No exceptions.
Fix: Make {% extends %} the absolute first thing in the file.
2. Undefined Variables Show Nothing
Problem: {{ username }} renders as empty instead of showing an error.
Cause: Frond outputs nothing for undefined variables. By design.
Fix: Use the default filter: {{ username | default("Guest") }}.
3. Auto-Escaping Prevents HTML Output
Problem: You pass HTML content but it appears as literal text.
Cause: Auto-escaping converts < to < for security.
Fix: For trusted content, use {{ content | raw }}. Never use raw on user-supplied input.
4. Variable Scope in Includes
Problem: A variable defined inside a {% for %} loop is not accessible after the loop ends.
Cause: Loop variables are scoped to the loop.
Fix: Use {% set %} before the loop to accumulate values.
5. Macro Arguments Are Positional
Problem: Calling {{ button("Click", style="danger") }} does not work.
Cause: Frond macros use positional arguments, not keyword arguments.
Fix: Pass arguments in the order defined: {{ button("Click", "/url", "danger") }}.
6. Template File Extension Does Not Matter
Problem: Not sure whether to use .html, .twig, or .tpl.
Cause: Frond does not care about the file extension. It processes any file in src/templates/.
Fix: Pick one extension. Be consistent. This book uses .twig for templates with Twig syntax and .html for simple HTML files.
7. Filters Are Not JavaScript Functions
Problem: You try {{ items | count }} or {{ name | toUpperCase }} and get an error.
Cause: Frond filters follow Twig conventions, not JavaScript conventions.
Fix: Use {{ items | length }} instead of count. Use {{ name | upper }} instead of toUpperCase.