Views & Templates
Chapter 6: Building user interfaces with Jinja2, HTMX, and Bootstrap.
Views are what users actually see—the HTML pages that display your data. sparQ uses Jinja2 templates combined with HTMX for interactivity and Bootstrap for styling.
Template Basics
Templates live in views/templates/myapp/desktop/ within your app folder. They extend the base layout:
{% extends "core/desktop/base.html" %}
{% block content %}
<div class="container py-4">
<h1>My Tasks</h1>
<p>You have {{ tasks|length }} tasks.</p>
</div>
{% endblock %}
The {% extends "core/desktop/base.html" %} gives you the full sparQ layout with header, sidebar, and footer.
Displaying Data
Use {{ }} to output variables passed from your controller:
<!-- Simple variable -->
<h1>{{ task.title }}</h1>
<!-- With filter -->
<p>Created {{ task.created_at|timeago }}</p>
<!-- Conditional -->
{% if task.done %}
<span class="badge bg-success">Done</span>
{% else %}
<span class="badge bg-warning">Pending</span>
{% endif %}
<!-- Loop -->
{% for task in tasks %}
<div>{{ task.title }}</div>
{% endfor %}
Global Template Variables
These variables are always available in templates:
| Variable | Description |
|---|---|
current_user |
The logged-in user (or anonymous) |
g.current_module |
Current module's manifest |
g.lang |
Current language code ("en", "es", etc.) |
<!-- Show current user -->
{% if current_user.is_authenticated %}
Welcome, {{ current_user.name }}!
{% else %}
<a href="{{ url_for('core.auth.login') }}">Log in</a>
{% endif %}
Template Functions
Translation with _()
<h1>{{ _("Welcome") }}</h1>
<button>{{ _("Save") }}</button>
URLs with url_for()
<a href="{{ url_for('tasks.main.index') }}">All Tasks</a>
<a href="{{ url_for('tasks.main.show', id=task.id) }}">View</a>
Check if Module Exists
{% if module_enabled('billing') %}
<a href="{{ url_for('billing.invoices.index') }}">Invoices</a>
{% endif %}
HTMX for Interactivity
HTMX lets you build dynamic interfaces without writing JavaScript. Add attributes to HTML elements to make them send AJAX requests:
Click to Load
<button hx-get="/tasks/more"
hx-target="#task-list"
hx-swap="beforeend">
Load More
</button>
<div id="task-list">
<!-- Tasks appear here -->
</div>
Search with Debounce
<input type="search"
name="q"
placeholder="Search tasks..."
hx-get="/tasks/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results">
<div id="results"></div>
Inline Edit
<!-- Display mode -->
<span hx-get="/tasks/{{ task.id }}/edit-form"
hx-trigger="click"
hx-swap="outerHTML"
class="editable">
{{ task.title }}
</span>
<!-- Edit mode (returned by /edit-form) -->
<form hx-post="/tasks/{{ task.id }}"
hx-swap="outerHTML">
<input name="title" value="{{ task.title }}">
<button>Save</button>
</form>
Form Submission
<form hx-post="/tasks/create"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()">
<input name="title" placeholder="New task..." required>
<button>Add</button>
</form>
Bootstrap Components
sparQ includes Bootstrap 5. Use its classes for consistent styling:
Cards
<div class="card">
<div class="card-header">Task Details</div>
<div class="card-body">
<h5 class="card-title">{{ task.title }}</h5>
<p class="card-text">{{ task.description }}</p>
</div>
</div>
Tables
<table class="table table-hover">
<thead>
<tr>
<th>Task</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.title }}</td>
<td>
<span class="badge bg-{{ 'success' if task.done else 'secondary' }}">
{{ "Done" if task.done else "Pending" }}
</span>
</td>
<td>
<a href="{{ url_for('tasks.main.edit', id=task.id) }}"
class="btn btn-sm btn-outline-primary">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
Forms
<form method="POST">
<div class="mb-3">
<label class="form-label">Task Title</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">Save Task</button>
</form>
Alpine.js for Local State
Use Alpine.js for small UI interactions like toggles and dropdowns:
<div x-data="{ open: false }">
<button @click="open = !open">
Toggle Details
</button>
<div x-show="open" x-transition>
These are the hidden details.
</div>
</div>
Template File Structure
Templates are organized in a specific folder structure:
The nested templates/myapp/desktop/ structure ensures template names don't collide across apps. When rendering, reference templates as myapp/desktop/index.html.
Device-Aware Templates
Desktop templates must be placed in a desktop/ subfolder. Mobile templates are optional and go in a mobile/ subfolder with the same file name.
Use render_device_template() in your controllers instead of Flask's render_template. It automatically serves the mobile version on mobile devices and falls back to desktop if no mobile template exists:
from system.device.template import render_device_template
# Always pass the desktop path — mobile is resolved automatically
return render_device_template('myapp/desktop/index.html', tasks=tasks)
To test mobile rendering during development, append ?device=mobile to any URL.
Partials for Reusability
Extract repeated HTML into partial templates:
<!-- views/templates/tasks/desktop/partials/task_row.html -->
<tr>
<td>{{ task.title }}</td>
<td>{{ task.status }}</td>
</tr>
<!-- Use it in your main template -->
{% for task in tasks %}
{% include "tasks/desktop/partials/task_row.html" %}
{% endfor %}
Key Takeaways
- Templates extend
core/desktop/base.htmlfor the full layout - Use
{{ }}for variables,{% %}for logic current_user,_(), andurl_for()are always available- HTMX adds interactivity without JavaScript
- Use Bootstrap classes for consistent styling
- Extract repeated HTML into partials
- Desktop templates must be in a
desktop/subfolder; mobile is optional