htmx Tutorial · Going Further · Lesson 26 of 30
The Server Side: Templates and Partials
Organise server templates for htmx: return fragments to htmx and full pages to normal visits, with examples in Flask, Django, Express and Laravel.
One URL, two answers
The same URL often needs to return:
- a full page for a normal visit (typing the URL, a reload, a search engine), and
- just the fragment that changes, for an htmx request.
Check the HX-Request header to decide. Then tell caches that the response depends on it with
Vary: HX-Request, or a cache could serve a fragment to a normal visitor.
from flask import Flask, render_template, request
app = Flask(__name__)
@app.get("/contacts")
def contacts():
q = request.args.get("q", "")
rows = search_contacts(q) # your database query
if request.headers.get("HX-Request") and not request.headers.get("HX-History-Restore-Request"):
html = render_template("contacts/_rows.html", rows=rows) # just the <tr>s
else:
html = render_template("contacts/index.html", rows=rows, q=q) # whole page, includes _rows.html
return html, {"Vary": "HX-Request"}
from django.shortcuts import render
from django.utils.cache import patch_vary_headers
def contacts(request):
rows = search_contacts(request.GET.get("q", ""))
is_htmx = request.headers.get("HX-Request") and not request.headers.get("HX-History-Restore-Request")
template = "contacts/_rows.html" if is_htmx else "contacts/index.html"
response = render(request, template, {"rows": rows})
patch_vary_headers(response, ["HX-Request"])
return response
app.get("/contacts", async (req, res) => {
const rows = await searchContacts(req.query.q ?? "");
const isHtmx = req.get("HX-Request") && !req.get("HX-History-Restore-Request");
res.vary("HX-Request");
res.render(isHtmx ? "contacts/_rows" : "contacts/index", { rows });
});
Route::get('/contacts', function (Request $request) {
$rows = Contact::search($request->query('q', ''))->get();
$isHtmx = $request->hasHeader('HX-Request') && ! $request->hasHeader('HX-History-Restore-Request');
return response()
->view($isHtmx ? 'contacts._rows' : 'contacts.index', ['rows' => $rows])
->header('Vary', 'HX-Request');
});
Why check HX-History-Restore-Request too? When htmx can't find a page in its history cache, it re-requests the
URL to restore it, and in htmx 2 that request also carries HX-Request: true. It needs the full page.
Template partials
Keep the fragment in its own template file and include it from the full page. The fragment is written once, and both answers stay identical:
<!-- contacts/index.html -->
{% extends "base.html" %}
{% block content %}
<input type="search" name="q" value="{{ q }}" hx-get="/contacts" hx-trigger="input changed delay:300ms"
hx-target="#rows" hx-push-url="true">
<table><tbody id="rows">{% include "contacts/_rows.html" %}</tbody></table>
{% endblock %}
Libraries such as django-htmx, htmx-flask and Laravel htmx packages add small helpers (request.htmx)
for these checks.
<input type="search" name="q" placeholder="Filter contacts" hx-get="/contacts"
hx-trigger="input changed delay:300ms" hx-target="#rows">
<table><tbody id="rows"><tr><td>Ada</td></tr><tr><td>Alan</td></tr><tr><td>Grace</td></tr></tbody></table>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
const names = ["Ada", "Alan", "Grace"];
const rows = (q) => names.filter((n) => n.toLowerCase().includes(q.toLowerCase()))
.map((n) => `<tr><td>${n}</td></tr>`).join("");
server.get("/contacts", (req) => {
const q = req.query.q || "";
const body = req.headers["hx-request"]
? rows(q) // htmx: just the rows
: `<html><body><input name="q"><table><tbody id="rows">${rows(q)}</tbody></table></body></html>`;
return { body, headers: { Vary: "HX-Request" } };
});
</script>
Test yourself
Why send Vary: HX-Request when a URL returns either a fragment or a full page?
So caches store the two versions separatelyhtmx refuses responses without itIt makes the response smallerBrowsers require it for AJAX
Show answer
Answer: So caches store the two versions separately
Without Vary, a browser or CDN cache could hand a fragment to a normal visitor.