Rendering HTML with templates¶
A route (or middleware) can render server-side HTML instead of returning its content
directly: set template on a full-control response (see Requests and
responses), or declare a default template on the route
itself, and rel renders it with Jet, a Go template engine,
instead of serializing the second OUT column (or, for a plain-return route with a declared
default template, its own return value).
create function hotel.booking_confirmed(req jsonb, out resp jsonb, out content jsonb)
returns record language plpgsql as $$
begin
resp := jsonb_build_object('status', 200, 'content_type', 'text/html', 'template', 'booking-confirmed.jet');
content := jsonb_build_object('guest_name', 'Alex');
end;
$$;
template is a path relative to http.templates.path (default /template; see
Configuration reference). content_type is never implied by template — a
route rendering HTML via a template still sets content_type: "text/html" itself, the same
as it would for any other response. A template that fails to load (missing file, parse error)
or fails during execution (referencing a field Data/Req doesn't have) is a 500, logged
with the template path, never a silent fallback to the function's own return value. A route
returning a bytea/binary mimetype domain must not also set template — rel disables it with
a warning at discovery time, since Jet only ever renders text.
What a template can see¶
The template executes with three named variables in scope:
| Variable | Value |
|---|---|
Data |
The second OUT column (full-control), or the route's own return value (plain-return) — JSON null for a text/binary shape wraps into a plain string first, so {{ Data }} always reflects what the function actually returned. |
Req |
The exact HttpRequest the function itself received — {{ Req.uri }}, {{ Req.jwt.role }}. |
Nonce |
The request's CSP nonce (Req.csp_nonce) — see below. |
Nonce: trusted inline scripts without loosening the CSP¶
This is the reason templates exist as more than a convenience. rel generates a fresh,
cryptographically random nonce for every request and enforces a Content-Security-Policy that
by default blocks inline <script>/<style> entirely (see CORS and CSP).
{{ Nonce }} is how a template opts a specific inline block back in, without disabling the
policy for the whole response:
<!-- template/booking-confirmed.jet -->
<h1>Booking confirmed for {{ Data.guest_name }}</h1>
<script nonce="{{ Nonce }}">/* trusted inline code */</script>
rel appends 'nonce-<value>' to the response's script-src/style-src CSP directives to
match — the two always agree, so a nonce read off Req.csp_nonce in hand-built HTML and a
nonce read from {{ Nonce }} in a template are equally valid. A <script>/<style> tag
without a matching nonce (or without 'unsafe-inline' explicitly configured) simply doesn't
run, browser-enforced — this is what makes returning raw HTML from a route safe by default
even though the response is dynamic.
Jet auto-escapes every {{ value }} interpolation for HTML; the one place that escaping is
wrong is inside a <script> block, since HTML-escaping a value doesn't produce valid or safe
JavaScript. Pass dynamic data into an inline script through a JSON island instead of
interpolating it directly:
json(...) is one of Jet's built-in globals — encoding/json.Marshal under the hood, already
escaped correctly for a JSON-typed <script> body (Go's JSON encoder escapes <, >, and &
inside strings, so a </script>-shaped value can't break out of the block). | raw is
required on top of it: piping already-correct JSON through Jet's default HTML escaper would
mangle those escapes a second time.
Layouts: extends, block, and yield¶
Jet templates can extend one another, the same inheritance model as Django or Twig: a base layout declares named blocks, and a page extending it overrides the ones it wants to fill in.
<!-- template/layout.jet -->
<!doctype html>
<html>
<head><title>{{ block "title" }}rel{{ end }}</title></head>
<body>
<script nonce="{{ Nonce }}">/* shared boilerplate */</script>
{{ block "content" }}{{ end }}
</body>
</html>
<!-- template/booking-confirmed.jet -->
{{ extends "layout.jet" }}
{{ block "title" }}Booking confirmed{{ end }}
{{ block "content" }}
<h1>Booking confirmed for {{ Data.guest_name }}</h1>
{{ end }}
{{ extends "layout.jet" }} resolves the same way template itself does — relative to
http.templates.path — so a layout used by several routes' templates lives alongside them in
that same directory tree, not somewhere separate. Data, Req, and Nonce stay in scope
across the whole chain: a value read in a block inherited from the base layout is exactly the
Data/request the function itself set, not something the child template has to re-thread
through.
{{ import "partials.jet" }} (reusable macros, shared across many templates without an
inheritance relationship) and {{ include "footer.jet" }} (inline another template's full
output at that point) are both available too, resolved the same way.
rel(): reading data from a template¶
Every template — a route's own, or a static .jet file (see Static files ## Rendering a .jet
template) — has a rel(query) function in scope,
running a read-only query with the requesting user's own role, the same role a /rel
request from that user would resolve to:
{{ range _, movie := rel(map("relation", "movie", "schema", "public", "select", slice("own"))) }}
<li>{{ movie.title }}</li>
{{ end }}
query is built with Jet's own map()/slice() globals into the same shape a /rel request
body would send — a bare relation, or a well-known query by name (map("wellknown", "name",
"params", map(...))). A write shape (map("query", ..., "data", ...)) is always rejected: a
page render must not have side effects. rel() opens its own short-lived transaction per call,
independent of the surrounding request's own transaction (if any) and of any other rel() call
in the same render — it is not a mechanism for a coherent multi-statement read; reach for a
database function instead when several queries must see one consistent snapshot.
Warning
rel() has no query timeout, row limit, or execution-step limit of its own. A static .jet
file is normally the least authenticated, most-crawled surface of a site — an expensive
rel() call there is a standing amplification risk against the database on every anonymous
page load, not just under active abuse. Keep queries cheap and bounded (an explicit
limit), and rely on the database's own role-scoped grants/RLS for what a given caller can
see, exactly as /rel does.
Reload¶
The template set (and its parse cache) rebuilds as part of the same SIGUSR1 reload sequence a
schema change uses — see Operations. A changed .jet file on
disk takes effect the next time an operator reloads, not on every request; there is no
development mode that reparses from disk on every render. This applies identically to a
static-served .jet file — editing one under http.static.path has no effect until reload.