Chapter 4: Templates#
1. Beyond JSON -- Rendering HTML#
Every route so far returns JSON. That works for APIs. Web applications need HTML -- product listings, dashboards, login forms, email templates. Tina4 uses the Frond template engine for this work.
Frond is a zero-dependency template engine built from scratch. Its syntax matches Twig, Jinja2, and Nunjucks. Three constructs drive the entire engine: {{ }} for output, {% %} for logic, {# #} for comments. That is the whole grammar.
This chapter builds toward a product catalog page. Items in a grid. Featured products highlighted. Prices formatted. Layout inherited from a shared template. One engine handles it all.
2. The @template Decorator#
The shortest path to a rendered page:
from tina4_python.core.router import get, templateโ@get("/about")@template("about.html")async def about_page(request, response): return { "title": "About Us", "company": "My Store", "founded": 2020 }Decorator order matters.
@templatemust sit below the route decorator (@get/@post/...) so the template wrapper is what gets registered with the router. If@templateis above the route decorator, the router only sees the raw handler and the template wrapping is never applied, so the page renders a bare dict.
Create src/templates/about.html:
<!DOCTYPE html><html><head><title>{{ title }}</title></head><body> <h1>{{ title }}</h1> <p>{{ company }} was founded in {{ founded }}.</p></body></html>Visit http://localhost:7146/about and the rendered page appears.
The @template decorator stacks above @get (or @post, etc.). When the handler returns a dictionary, the decorator passes that dict to response.render() with the named template. If the handler returns something other than a dict -- an already-built Response, for instance -- the decorator passes it through unchanged. You can also call response.render() directly in any route handler. The decorator is shorthand.
3. Variables and Output#
Basic Output#
Double curly braces print a variable:
<h1>Hello, {{ name }}!</h1><p>Your balance is {{ balance }}.</p>With data {"name": "Alice", "balance": 150.50}, this renders:
<h1>Hello, Alice!</h1><p>Your balance is 150.5.</p>Accessing Nested Properties#
Dot notation reaches into dictionaries and object attributes:
<p>{{ user.name }}</p><p>{{ user.address.city }}</p><p>{{ order.items.0.name }}</p>With data:
{ "user": { "name": "Alice", "address": {"city": "Cape Town"} }, "order": { "items": [{"name": "Keyboard"}, {"name": "Mouse"}] }}Renders:
<p>Alice</p><p>Cape Town</p><p>Keyboard</p>Method Calls and Slicing#
Frond supports calling methods on values inside templates. If a dictionary value is callable, you invoke it with arguments:
{{ user.t("greeting") }} {# calls user.t("greeting") #}{{ text[:10] }} {# slice syntax -- first 10 characters #}{{ items[2:5] }} {# slice from index 2 to 5 #}Operators inside quoted function arguments (such as +, -, *) parse without breaking the expression.
Auto-Escaping#
Frond escapes HTML characters in output by default. XSS attacks die here:
<p>{{ user_input }}</p>With {"user_input": "<script>alert('hacked')</script>"}, this renders:
<p><script>alert('hacked')</script></p>The script tag becomes plain text. It never executes. If you need raw HTML output and you trust the source, use the |safe filter:
<div>{{ trusted_html | safe }}</div>4. Filters#
Filters transform output. The pipe | applies them:
{{ name | upper }} {# ALICE #}{{ name | lower }} {# alice #}{{ name | capitalize }} {# Alice #}{{ name | title }} {# Alice Smith #}{{ bio | trim }} {# Removes leading/trailing whitespace #}Complete Filter Reference#
String Filters#
| Filter | Example | Description |
|---|
| upper | {{ name \| upper }} | Convert to uppercase | | lower | {{ name \| lower }} | Convert to lowercase | | capitalize | {{ name \| capitalize }} | Capitalize first letter | | title | {{ name \| title }} | Capitalize each word | | trim | {{ name \| trim }} | Strip leading/trailing whitespace | | ltrim | {{ name \| ltrim }} | Strip leading whitespace | | rtrim | {{ name \| rtrim }} | Strip trailing whitespace | | slug | {{ title \| slug }} | Convert to URL-friendly slug | | wordwrap(80) | {{ text \| wordwrap(80) }} | Wrap text at N characters | | truncate(100) | {{ text \| truncate(100) }} | Truncate to N characters with ellipsis | | nl2br | {{ text \| nl2br }} | Convert newlines to <br> tags | | striptags | {{ html \| striptags }} | Remove all HTML tags | | replace("a", "b") | {{ text \| replace("old", "new") }} | Replace occurrences of a substring |
Array Filters#
| Filter | Example | Description |
|---|
| length | {{ items \| length }} | Count items in array or string length | | reverse | {{ items \| reverse }} | Reverse order of items | | sort | {{ items \| sort }} | Sort items ascending | | shuffle | {{ items \| shuffle }} | Randomly shuffle items | | first | {{ items \| first }} | Get the first item | | last | {{ items \| last }} | Get the last item | | join(", ") | {{ items \| join(", ") }} | Join array items with separator | | split(",") | {{ csv \| split(",") }} | Split string into array | | unique | {{ items \| unique }} | Remove duplicate values | | filter | {{ items \| filter }} | Remove falsy values from array | | map("name") | {{ items \| map("name") }} | Extract a property from each item | | column("name") | {{ items \| column("name") }} | Extract a column from array of objects | | batch(3) | {{ items \| batch(3) }} | Group items into batches of N | | slice(0, 3) | {{ items \| slice(0, 3) }} | Extract a slice from offset with length |
Encoding Filters#
| Filter | Example | Description |
|---|
| escape (e) | {{ text \| escape }} | HTML-escape special characters | | raw (safe) | {{ html \| raw }} | Output without auto-escaping | | url_encode | {{ text \| url_encode }} | URL-encode a string | | base64_encode (base64encode) | {{ text \| base64_encode }} | Base64-encode a string | | base64_decode (base64decode) | {{ data \| base64_decode }} | Base64-decode a string | | md5 | {{ text \| md5 }} | Compute MD5 hash | | sha256 | {{ text \| sha256 }} | Compute SHA-256 hash |
Numeric Filters#
| Filter | Example | Description |
|---|
| abs | {{ num \| abs }} | Absolute value | | round(2) | {{ price \| round(2) }} | Round to N decimal places | | number_format(2) | {{ price \| number_format(2) }} | Format with decimals and thousands separator | | int | {{ val \| int }} | Cast to integer | | float | {{ val \| float }} | Cast to float | | string | {{ val \| string }} | Cast to string |
JSON Filters#
| Filter | Example | Description |
|---|
| json_encode | {{ data \| json_encode }} | Encode value as JSON string | | to_json (tojson) | {{ data \| to_json }} | Encode value as JSON string (alias) | | json_decode | {{ str \| json_decode }} | Decode JSON string to object | | js_escape | {{ text \| js_escape }} | Escape string for safe use in JavaScript |
Dict Filters#
| Filter | Example | Description |
|---|
| keys | {{ obj \| keys }} | Get dictionary keys as array | | values | {{ obj \| values }} | Get dictionary values as array | | merge(other) | {{ defaults \| merge(overrides) }} | Merge two dictionaries |
Other Filters#
| Filter | Example | Description |
|---|
| default("fallback") | {{ name \| default("Guest") }} | Fallback when value is empty or undefined | | date("Y-m-d") | {{ created \| date("Y-m-d") }} | Format a date value | | format(val) | {{ "%.2f" \| format(price) }} | Format string with value (sprintf-style) | | data_uri | {{ content \| data_uri }} | Convert to a data URI string | | dump | {{ var \| dump }} or {{ dump(var) }} | Debug output, gated on TINA4_DEBUG=true (see Dumping Values) | | form_token | {{ form_token() }} | Generate a CSRF hidden input with token | | formTokenValue | {{ formTokenValue("context") }} | Return the raw JWT token string | | to_json | {{ data \| to_json }} | JSON-encode a value (no double-escaping) | | js_escape | {{ text \| js_escape }} | Escape for safe use in JavaScript strings |
Chaining Filters#
Filters chain left to right:
{{ name | trim | lower | capitalize }}{# " alice smith " โ "alice smith" โ "Alice smith" #} {{ items | sort | reverse | first }}{# Sort, reverse, take first = largest item #}The default Filter#
A fallback when a variable is empty or undefined:
{{ username | default("Guest") }}{{ bio | default("No bio provided.") }}{{ theme | default("light") }}Dumping Values for Debugging#
The dump helper lets you inspect any variable mid-template. Two interchangeable forms are supported:
{{ user | dump }}{{ dump(user) }}Both produce the same <pre>-wrapped, HTML-escaped repr() of the value. Handles nested dicts, lists, class instances, and cyclic references without crashing, since Python's repr() prints {...} for back-edges.
{{ dump(order) }} {# Output: #}{# <pre>{'id': 42, 'items': [...], 'total': 99.99}</pre> #}dump is gated on TINA4_DEBUG=true. In production (env var unset or false) both the filter and function form silently return an empty string. This prevents accidental leaks of internal state, object shapes, or sensitive values into rendered HTML if a developer leaves a {{ dump(x) }} call in a template.
# .env - devTINA4_DEBUG=true # dump() outputs the valueโ# .env - productionTINA4_DEBUG=false # dump() is a no-opYou can rely on this gate for safety, but treat dump as a development-only convenience. For structured output in production code paths, use to_json.
5. Control Tags#
if / elif / else#
{% if user.role == "admin" %} <span class="badge">Admin</span>{% elif user.role == "moderator" %} <span class="badge">Moderator</span>{% else %} <span class="badge">Member</span>{% endif %}Comparisons and logical operators:
{% if price > 100 and in_stock %} <p>Premium item, available now!</p>{% endif %} {% if not user.verified %} <p>Please verify your email.</p>{% endif %} {% if not items %} <p>Your cart is empty.</p>{% endif %}for Loops#
{% for product in products %} <div class="product-card"> <h3>{{ product.name }}</h3> <p>${{ "%.2f"|format(product.price) }}</p> </div>{% endfor %}Inside a for loop, the loop variable provides iteration context:
| Variable | Description |
|---|---|
loop.index | Current iteration (1-based) |
loop.index0 | Current iteration (0-based) |
loop.first | True on the first iteration |
loop.last | True on the last iteration |
loop.length | Total number of items |
<ol>{% for item in items %} <li class="{{ 'first' if loop.first else '' }} {{ 'last' if loop.last else '' }}"> {{ loop.index }}. {{ item.name }} </li>{% endfor %}</ol>for / else#
The {% else %} block inside {% for %} runs when the list is empty:
{% for product in products %} <div class="product-card">{{ product.name }}</div>{% else %} <p>No products found.</p>{% endfor %}set -- Local Variables#
Create or update a variable inside a template:
{% set greeting = "Hello" %}{% set full_name = user.first_name ~ " " ~ user.last_name %}{% set total = price * quantity %}{% set discount = total - rebate %} <p>{{ greeting }}, {{ full_name }}!</p><p>Total: {{ total }}, After discount: {{ discount }}</p>The ~ operator concatenates strings. Arithmetic operators (+, -, *, /, //, %, **) work in set and expressions.
When combining filters with arithmetic, assign the filtered values first:
{% set dr = account.dr|default(0) %}{% set cr = account.cr|default(0) %}{% set balance = dr - cr %}<p>Balance: {{ balance }}</p>6. Template Inheritance#
Template inheritance kills duplication. A base template defines blocks. Child templates override them. One layout file controls every page.
Base Template#
Create src/templates/base.html:
<!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 Store{% endblock %}</title> <link rel="stylesheet" href="/css/tina4.css"> {% block head %}{% endblock %}</head><body> <nav> <a href="/">Home</a> <a href="/products">Products</a> <a href="/about">About</a> </nav> <main> {% block content %}{% endblock %} </main> <footer> <p>© 2026 My Store</p> </footer> <script src="/js/frond.js"></script> {% block scripts %}{% endblock %}</body></html>Child Template#
Create src/templates/home.html:
{% extends "base.html" %} {% block title %}Home - My Store{% endblock %} {% block content %} <h1>Welcome to My Store</h1> <p>Browse our collection of quality products.</p>{% endblock %}When Frond renders home.html:
- It sees
{% extends "base.html" %}and loads the base template. - The
{% block title %}inhome.htmlreplaces the one inbase.html. - The
{% block content %}inhome.htmlreplaces the one inbase.html. - Blocks not overridden (
head,scripts) keep their default content -- empty here.
Calling Parent Blocks#
Use {{ parent() }} to include the parent block's content:
{% extends "base.html" %} {% block head %} {{ parent() }} <link rel="stylesheet" href="/css/products.css">{% endblock %}The head block now contains everything from the base plus the extra stylesheet.
7. Include and Macro#
include#
Pull in another template file:
{% include "partials/header.html" %} <main> <h1>Products</h1></main> {% include "partials/footer.html" %}Pass variables to included templates:
{% include "partials/product-card.html" with {"product": featured_product} %}macro -- Reusable Template Functions#
Macros are functions for templates. Define once, call everywhere:
Create src/templates/macros/forms.html:
{% macro input(name, label, type="text", value="", required=false) %} <div class="form-group"> <label for="{{ name }}">{{ label }}{% if required %} *{% endif %}</label> <input type="{{ type }}" name="{{ name }}" id="{{ name }}" value="{{ value }}" {{ "required" if required else "" }}> </div>{% endmacro %} {% macro textarea(name, label, value="", rows=4) %} <div class="form-group"> <label for="{{ name }}">{{ label }}</label> <textarea name="{{ name }}" id="{{ name }}" rows="{{ rows }}">{{ value }}</textarea> </div>{% endmacro %} {% macro button(text, type="submit", class="btn-primary") %} <button type="{{ type }}" class="btn {{ class }}">{{ text }}</button>{% endmacro %}Use them:
{% import "macros/forms.html" as forms %} <form method="POST" action="/api/contact"> {{ forms.input("name", "Your Name", required=true) }} {{ forms.input("email", "Email Address", type="email", required=true) }} {{ forms.textarea("message", "Your Message", rows=6) }} {{ forms.button("Send Message") }}</form>Change the macro once and every form in your application updates. Consistent markup across the entire project.
8. Comments#
Template comments use {# #}. Frond strips them from the output:
{# This comment will not appear in the HTML source #}<p>Visible content</p> {# Multi-line comments work too. Use them to document template logic.#}HTML comments (<!-- -->) reach the browser. Frond comments never do.
9. Special Tags#
{% raw %} -- Literal Output#
Output literal {{ }} or {% %} without processing. This tag saves you when embedding Vue.js or Angular templates:
{% raw %} <div id="app"> {{ message }} </div>{% endraw %}Frond outputs the literal text {{ message }}. No variable lookup. No expression parsing.
{% spaceless %} -- Remove Whitespace#
Strip whitespace between HTML tags:
{% spaceless %} <div> <span>Hello</span> </div>{% endspaceless %}Output:
<div><span>Hello</span></div>Inline elements create visible gaps when whitespace sits between them. The spaceless tag eliminates those gaps.
{% autoescape %} -- Control Escaping#
Override auto-escaping for a block of content:
{% autoescape false %} {{ trusted_html }}{% endautoescape %}Everything inside outputs without HTML escaping. This works the same as | raw on every variable, but handles large blocks of trusted content with less repetition. Never use this with user-submitted data.
Whitespace Control#
Template tags occupy a full line and produce blank lines in the output. Use {%- and -%} to strip surrounding whitespace:
{%- for item in items -%} <li>{{ item.name }}</li>{%- endfor -%}The - on the left strips whitespace before the tag. The - on the right strips whitespace after. The output contains no blank lines between list items.
10. tina4css#
The tina4.css file is Tina4's built-in CSS utility framework. It ships with every project. Layout utilities. Typography. Spacing. Common UI patterns. No Bootstrap. No Tailwind. No separate download.
Include it in your base template:
<link rel="stylesheet" href="/css/tina4.css">Layout Classes#
The grid system uses a 12-column layout:
<div class="container"> <div class="row"> <div class="col-6">Left half</div> <div class="col-6">Right half</div> </div></div>Flex layout for alignment:
<div class="flex justify-between items-center"> <h1>Title</h1> <button class="btn btn-primary">Action</button></div>CSS grid for card layouts:
<div class="grid grid-cols-3 gap-4"> <div class="card">Item 1</div> <div class="card">Item 2</div> <div class="card">Item 3</div></div>Buttons#
Five button styles cover most use cases:
<button class="btn btn-primary">Primary</button><button class="btn btn-secondary">Secondary</button><button class="btn btn-success">Success</button><button class="btn btn-warning">Warning</button><button class="btn btn-danger">Danger</button>Link-style buttons use the same classes on anchor tags:
<a href="/dashboard" class="btn btn-primary">Go to Dashboard</a><a href="/cancel" class="btn btn-secondary">Cancel</a>Cards#
Cards group related content with optional header and footer sections:
<div class="card"> <div class="card-header">Order Summary</div> <div class="card-body"> <p>3 items in your cart</p> <p class="text-primary">Total: $149.97</p> </div> <div class="card-footer"> <button class="btn btn-primary">Checkout</button> </div></div>Cards work well inside a grid for catalog-style layouts:
<div class="grid grid-cols-3 gap-4"> {% for product in products %} <div class="card"> <div class="card-header">{{ product.name }}</div> <div class="card-body"> <p>${{ "%.2f"|format(product.price) }}</p> </div> </div> {% endfor %}</div>Alerts#
Alert boxes communicate status messages to the user:
<div class="alert alert-success">Order placed. Check your email for confirmation.</div><div class="alert alert-danger">Payment failed. Your card was declined.</div><div class="alert alert-warning">Your session expires in 5 minutes.</div><div class="alert alert-info">New features are available. See the changelog.</div>Forms#
Form controls use form-group for spacing and form-control for input styling:
<form method="POST" action="/api/contact"> <div class="form-group"> <label for="name">Full Name</label> <input type="text" id="name" name="name" class="form-control" required> </div> <div class="form-group"> <label for="email">Email Address</label> <input type="email" id="email" name="email" class="form-control" required> </div> <div class="form-group"> <label for="message">Message</label> <textarea id="message" name="message" class="form-control" rows="4"></textarea> </div> <div class="form-group"> <label for="priority">Priority</label> <select id="priority" name="priority" class="form-control"> <option value="low">Low</option> <option value="medium" selected>Medium</option> <option value="high">High</option> </select> </div> <button type="submit" class="btn btn-primary">Send Message</button> <button type="reset" class="btn btn-secondary">Clear</button></form>Tables#
Tables gain borders and row striping with tina4css classes:
<table class="table"> <thead> <tr> <th>#</th> <th>Product</th> <th>Price</th> </tr> </thead> <tbody> {% for product in products %} <tr> <td>{{ loop.index }}</td> <td>{{ product.name }}</td> <td>${{ "%.2f"|format(product.price) }}</td> </tr> {% endfor %} </tbody></table>Spacing and Typography Utilities#
{# Spacing #}<div class="p-4 m-2">Padded and margined</div><div class="mt-4">Margin top</div><div class="mb-2">Margin bottom</div><div class="px-3">Horizontal padding</div> {# Typography #}<p class="text-lg text-gray-600">Large gray text</p><p class="text-center">Centered text</p><p class="text-right">Right-aligned text</p><span class="text-muted">Gray text</span><span class="text-primary">Primary color text</span>No external dependencies. If you prefer Bootstrap or Tailwind, swap the <link> tag. Tina4 does not care which CSS framework you choose.
11. Exercise: Build a Product Catalog Page#
Build a product catalog page with categories, filtering, and a detail view.
Requirements#
- Create a route at
GET /catalogthat renders a product catalog page - Create a route at
GET /catalog/{id:int}that renders a single product detail page - Use template inheritance with a base layout
- Display products in a grid with:
- Product name, price (formatted to 2 decimal places), category
- "In Stock" / "Out of Stock" badge
- Featured products get a highlighted border
- Show category filter links at the top (all categories from the data)
- Support
?category=query parameter to filter products - The detail page shows full product info including description
- Use at least one macro (e.g., for the product card)
- Use the
|defaultfilter somewhere meaningful
Use this data:
products = [ {"id": 1, "name": "Espresso Machine", "category": "Kitchen", "price": 299.99, "in_stock": True, "featured": True, "description": "Professional-grade espresso machine with 15-bar pressure pump and built-in grinder."}, {"id": 2, "name": "Yoga Mat", "category": "Fitness", "price": 29.99, "in_stock": True, "featured": False, "description": "Extra-thick 6mm yoga mat with non-slip surface. Available in 5 colors."}, {"id": 3, "name": "Standing Desk", "category": "Office", "price": 549.99, "in_stock": True, "featured": True, "description": "Electric sit-stand desk with memory presets and cable management tray."}, {"id": 4, "name": "Noise-Canceling Headphones", "category": "Electronics", "price": 199.99, "in_stock": False, "featured": True, "description": "Wireless headphones with adaptive noise canceling and 30-hour battery life."}, {"id": 5, "name": "Water Bottle", "category": "Fitness", "price": 24.99, "in_stock": True, "featured": False, "description": "Insulated stainless steel bottle, keeps drinks cold for 24 hours."}, {"id": 6, "name": "Desk Lamp", "category": "Office", "price": 79.99, "in_stock": True, "featured": False, "description": "LED desk lamp with adjustable color temperature and brightness."}]12. Solution#
Create src/templates/macros/catalog.html:
{% macro product_card(product) %} <div class="product-card {{ 'featured' if product.featured else '' }}"> {% if product.featured %} <span class="featured-badge">Featured</span> {% endif %} <h3><a href="/catalog/{{ product.id }}">{{ product.name }}</a></h3> <p class="category">{{ product.category }}</p> <p class="price">${{ "%.2f"|format(product.price) }}</p> {% if product.in_stock %} <span class="badge badge-success">In Stock</span> {% else %} <span class="badge badge-danger">Out of Stock</span> {% endif %} </div>{% endmacro %}Create src/templates/catalog.html:
{% extends "base.html" %}{% import "macros/catalog.html" as catalog %} {% block title %}{{ page_title | default("Product Catalog") }}{% endblock %} {% block content %} <h1>{{ page_title | default("Product Catalog") }}</h1> <div class="category-filters"> <a href="/catalog" class="{{ 'active' if active_category is not defined else '' }}">All</a> {% for cat in categories %} <a href="/catalog?category={{ cat }}" class="{{ 'active' if active_category == cat else '' }}">{{ cat }}</a> {% endfor %} </div> <p class="stats">Showing {{ products | length }} product{{ "s" if products|length != 1 else "" }}</p> <div class="product-grid"> {% for product in products %} {{ catalog.product_card(product) }} {% else %} <p>No products found in this category.</p> {% endfor %} </div>{% endblock %}Create src/templates/product_detail.html:
{% extends "base.html" %} {% block title %}{{ product.name }} - My Store{% endblock %} {% block content %} <a href="/catalog">← Back to catalog</a> <div class="product-detail"> <h1>{{ product.name }}</h1> <p class="category">{{ product.category }}</p> <p class="price">${{ "%.2f"|format(product.price) }}</p> {% if product.in_stock %} <span class="badge badge-success">In Stock</span> {% else %} <span class="badge badge-danger">Out of Stock</span> {% endif %} {% if product.featured %} <span class="featured-badge">Featured</span> {% endif %} <div class="description"> <h2>Description</h2> <p>{{ product.description | default("No description available.") }}</p> </div> </div>{% endblock %}Create src/routes/catalog.py:
from tina4_python.core.router import getโproducts = [ {"id": 1, "name": "Espresso Machine", "category": "Kitchen", "price": 299.99, "in_stock": True, "featured": True, "description": "Professional-grade espresso machine with 15-bar pressure pump and built-in grinder."}, {"id": 2, "name": "Yoga Mat", "category": "Fitness", "price": 29.99, "in_stock": True, "featured": False, "description": "Extra-thick 6mm yoga mat with non-slip surface. Available in 5 colors."}, {"id": 3, "name": "Standing Desk", "category": "Office", "price": 549.99, "in_stock": True, "featured": True, "description": "Electric sit-stand desk with memory presets and cable management tray."}, {"id": 4, "name": "Noise-Canceling Headphones", "category": "Electronics", "price": 199.99, "in_stock": False, "featured": True, "description": "Wireless headphones with adaptive noise canceling and 30-hour battery life."}, {"id": 5, "name": "Water Bottle", "category": "Fitness", "price": 24.99, "in_stock": True, "featured": False, "description": "Insulated stainless steel bottle, keeps drinks cold for 24 hours."}, {"id": 6, "name": "Desk Lamp", "category": "Office", "price": 79.99, "in_stock": True, "featured": False, "description": "LED desk lamp with adjustable color temperature and brightness."}]โโ@get("/catalog")async def catalog_page(request, response): category = request.params.get("category") categories = sorted(set(p["category"] for p in products))โ if category: filtered = [p for p in products if p["category"].lower() == category.lower()] else: filtered = productsโ return response.render("catalog.html", { "products": filtered, "categories": categories, "active_category": category, "page_title": f"{category} Products" if category else "Product Catalog" })โโ@get("/catalog/{id:int}")async def product_detail(id, request, response): product_id = idโ for product in products: if product["id"] == product_id: return response.render("product_detail.html", {"product": product})โ return response.render("errors/404.html", {}, 404)Open http://localhost:7146/catalog in your browser. You should see:
- A heading "Product Catalog"
- Category filter links: All, Electronics, Fitness, Kitchen, Office
- A count of products shown
- Product cards in a grid with names, prices, category labels, and stock badges
- Featured products wear a highlighted style and a "Featured" badge
- Clicking a product name navigates to the detail page
- Clicking a category link filters the list
13. Gotchas#
1. Whitespace in output#
Problem: Rendered HTML contains unexpected blank lines or spaces.
Cause: Template tags produce whitespace on the lines they occupy.
Fix: Use whitespace control with {%- and -%} to strip whitespace around tags:
{%- for item in items -%} <li>{{ item.name }}</li>{%- endfor -%}2. Variable not defined error#
Problem: Frond raises an error when a variable does not exist in the context.
Cause: You used {{ user.name }} but did not pass user in the template data.
Fix: Use the |default filter: {{ user.name | default("Guest") }}. Or check first: {% if user is defined %}{{ user.name }}{% endif %}.
3. Extends must be the first tag#
Problem: {% extends "base.html" %} has no effect. The page renders without the layout.
Cause: {% extends %} must be the first tag in the template. Any text, HTML, or tags before it cause Frond to treat the template as standalone.
Fix: Move {% extends "base.html" %} to the first line. Nothing before it.
4. Macro not found#
Problem: {{ forms.input(...) }} produces an error about forms being undefined.
Cause: The {% import %} statement is missing, or the import path is wrong.
Fix: Add {% import "macros/forms.html" as forms %} at the top of the template (after {% extends %} if using inheritance). The path is relative to src/templates/.
5. Filter produces wrong type#
Problem: {{ "%.2f"|format(price) }} shows an error instead of a formatted number.
Cause: The variable price is a string, not a number. Filters expect specific types.
Fix: Pass correct types from your route handler. Use float(price) in Python before passing to the template, or convert in the template: {{ "%.2f"|format(price|float) }}.
6. Escaped HTML when you want raw output#
Problem: HTML content shows as text with visible <tags> instead of rendering.
Cause: Frond auto-escapes all {{ }} output to prevent XSS.
Fix: Use the |safe filter: {{ trusted_html | safe }}. Only use this with content you trust -- never with user input.
7. Include file path wrong#
Problem: {% include "header.html" %} produces a "template not found" error even though the file exists.
Cause: The path in {% include %} is relative to src/templates/, not the current template file.
Fix: Use the full path from the templates root: {% include "partials/header.html" %} for a file at src/templates/partials/header.html.