Leaf Custom Tags and HTML DSLs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with Leaf 4.5.2 (LeafKit 1.14.3), Elementary 0.8.2, ElementaryHTMX 0.5.2, VaporElementary 0.2.2, Plot 0.14.0 and swift-html 0.5.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, and the behaviour described was verified with a VaporTesting suite and with curl against the running service; the output shown is captured from those runs. Elementary and VaporElementary declare macOS 14 as their minimum platform, so a package that uses them needs platforms: [.macOS(.v14)]. The Vapor 5 situation is in Version notes. The page extends the Bookshelf service of the earlier pages of this section: it renders the catalog as HTML in four ways. Leaf itself (setup, syntax, layouts, the built-in tags) is on the Leaf Templates page; here only what goes beyond it is covered. Swift is covered by the Swift Reference, and the builder syntax that the DSLs rely on by Result Builders. The same topic in Rust is in Server-Side Templates and htmx.

Where it sits

Rendering HTML is the last step of a handler, before Vapor encodes the value the handler returned into a Response. A Leaf view is produced by req.view.render(_:_:), which returns a View (a ResponseEncodable); a DSL page is produced by your own code and returned as a type that conforms to AsyncResponseEncodable. Custom Leaf tags run inside the render call, once for every place a template uses them. Nothing here is middleware: sessions (Sessions and Cookies), CSRF protection and security headers (Security Hardening) are separate layers that wrap the handler.

flowchart LR R[Request] --> M[Middleware chain] M --> H[Route handler] H --> L[req.view.render with a context] H --> D[A DSL page value] L --> T[Leaf runs tags while serialising] T --> V[View] D --> E[AsyncResponseEncodable.encodeResponse] V --> E2[View.encodeResponse] E --> RS[Response] E2 --> RS

Custom Leaf tags

A Leaf tag is the #name(parameters) syntax of a template. Everything built in (#count, #date, #lowercased, #contains, #isEmpty, #unsafeHTML, #comment, #dumpContext) is a LeafTag registered in a dictionary, and you extend Leaf by adding your own entries to that dictionary. The protocol is one method:

// LeafKit 1.14.3 (abridged)
public protocol LeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData
}

public protocol UnsafeUnescapedLeafTag: LeafTag {}

The two types that carry the data are small:

Type What it gives a tag

LeafContext.parameters

The resolved arguments, [LeafData]. Variables and expressions are evaluated before the tag runs, so #price(book.priceCents) receives an integer, not the text book.priceCents.

LeafContext.data

The complete template context at the point of the call, [String: LeafData]. Inside a #for loop it includes the loop variable. Use it for values the template author does not pass explicitly.

LeafContext.body

[Syntax]?, the parsed block of a tag written with a colon and an #endname (see Tags with a body).

LeafContext.userInfo

[AnyHashable: Any] that the renderer was created with. Leaf puts the Application under the key "application", and Leaf adds LeafContext.application to read it.

ctx.requireParameterCount(_:), ctx.requireBody(), ctx.requireNoBody()

Helpers that throw a LeafError when the call does not have the shape you expect.

LeafData

The value type of parameters and results. Read it with .string, .int, .double, .bool, .array, .dictionary (each is optional); build it with .string(_:), .int(_:), .bool(_:), .array(_:), .dictionary(_:), or .trueNil for "print nothing".

A tag returns one LeafData. Whatever it returns is serialised into the output at the tag’s position; returning .trueNil prints nothing (the built-in #comment does that). A tag cannot write to the output more than once, set variables for the rest of the template, or produce several values.

Registering a tag

A tag is registered under the name the template uses, usually in configure(_:):

app.leaf.tags["now"] = NowTag()

app.leaf.tags is a [String: any LeafTag] that starts as a copy of LeafKit’s defaultTags. Because it is a dictionary, registering with an existing name replaces the built-in tag with that name, which is occasionally useful and occasionally an accident. The renderer is built from the dictionary each time req.view or app.leaf.renderer is read, so register before the first request. A tag registered under a name that a template does not use costs nothing; a template that uses a name nobody registered does not fail, as the output below shows.

A complete set of tags

The listing below has the official #now example, a formatter, a tag that emits markup, one that reads the whole context and the application, the escaping helper that markup tags need, and a tag with a body. They are used by the template that follows it.

import Foundation
import Leaf
import LeafKit
import Vapor

// MARK: - The official docs example: #now()

enum NowTagError: Error {
    case invalidFormatParameter
    case tooManyParameters
}

struct NowTag: LeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        let formatter = DateFormatter()
        switch ctx.parameters.count {
        case 0: formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        case 1:
            guard let string = ctx.parameters[0].string else {
                throw NowTagError.invalidFormatParameter
            }
            formatter.dateFormat = string
        default:
            throw NowTagError.tooManyParameters
        }
        return LeafData.string(formatter.string(from: Date()))
    }
}

// MARK: - A value formatter: #price(cents) and #price(cents, "EUR")

struct PriceTag: LeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        guard (1...2).contains(ctx.parameters.count), let cents = ctx.parameters[0].int else {
            throw LeafError(.unknownError("#price(cents, currency?) expects an integer number of cents"))
        }
        let currency = ctx.parameters.count == 2 ? (ctx.parameters[1].string ?? "USD") : "USD"
        let symbol = ["USD": "$", "EUR": "€", "GBP": "£"][currency] ?? currency + " "
        let whole = cents / 100
        let fraction = cents % 100
        return .string("\(symbol)\(whole).\(fraction < 10 ? "0" : "")\(fraction)")
    }
}

// MARK: - A tag that emits HTML: #stars(rating)

/// Emits markup, so it conforms to `UnsafeUnescapedLeafTag`. Everything it interpolates is either a validated
/// integer or escaped here, because Leaf will not escape the result.
struct StarsTag: UnsafeUnescapedLeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        try ctx.requireParameterCount(1)
        guard let rating = ctx.parameters[0].int, (0...5).contains(rating) else {
            throw LeafError(.unknownError("#stars(rating) expects an integer between 0 and 5"))
        }
        let filled = String(repeating: "★", count: rating)
        let empty = String(repeating: "☆", count: 5 - rating)
        return .string(#"<span class="stars" role="img" aria-label="\#(rating) out of 5">\#(filled)\#(empty)</span>"#)
    }
}

// MARK: - Escaping text that goes into an unescaped tag

extension String {
    /// Escapes the five characters that matter in HTML text and quoted attribute values.
    var htmlEscaped: String {
        var result = ""
        result.reserveCapacity(count)
        for character in self {
            switch character {
            case "&": result += "&amp;"
            case "<": result += "&lt;"
            case ">": result += "&gt;"
            case "\"": result += "&quot;"
            case "'": result += "&#39;"
            default: result.append(character)
            }
        }
        return result
    }
}

/// #badge(status): an unescaped tag that takes user-controllable text, so it escapes it itself.
struct BadgeTag: UnsafeUnescapedLeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        try ctx.requireParameterCount(1)
        let text = ctx.parameters[0].string ?? ""
        return .string(#"<span class="badge">\#(text.htmlEscaped)</span>"#)
    }
}

/// The same tag written the wrong way: unescaped output, unescaped input.
struct UnsafeBadgeTag: UnsafeUnescapedLeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        .string("<span class=\"badge\">\(ctx.parameters.first?.string ?? "")</span>")
    }
}

// MARK: - Reading the context data and the application

/// #owned(): reads the whole template context through ctx.data instead of parameters.
struct GreetingTag: LeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        guard let name = ctx.data["viewer"]?.string else {
            throw LeafError(.unknownError("#greeting(): the context has no 'viewer' string"))
        }
        let environment = ctx.application?.environment.name ?? "unknown"
        return .string("Hello, \(name) (\(environment))")
    }
}

func registerLeafTags(_ app: Application) {
    app.leaf.tags["now"] = NowTag()
    app.leaf.tags["panel"] = PanelTag()
    app.leaf.tags["price"] = PriceTag()
    app.leaf.tags["stars"] = StarsTag()
    app.leaf.tags["badge"] = BadgeTag()
    app.leaf.tags["unsafeBadge"] = UnsafeBadgeTag()
    app.leaf.tags["greeting"] = GreetingTag()
}

// MARK: - A tag with a body: #panel("Title"): ... #endpanel

/// ctx.body is the parsed syntax tree of the block. Only literal text (.raw) can be turned back into a string with
/// the public API; anything that needs evaluating (#(x), #if, #for) is rejected.
struct PanelTag: UnsafeUnescapedLeafTag {
    func render(_ ctx: LeafContext) throws -> LeafData {
        try ctx.requireParameterCount(1)
        let body = try ctx.requireBody()
        var text = ""
        for node in body {
            guard case .raw(let buffer) = node else {
                throw LeafError(.unknownError("#panel only supports literal text in its body"))
            }
            text += String(buffer: buffer)
        }
        let title = (ctx.parameters[0].string ?? "").htmlEscaped
        return .string("<section class=\"panel\"><h2>\(title)</h2>\(text)</section>")
    }
}

The data every example on this page renders is a small read-only view of a book, with a search function (page-local types, not the Fluent models of the earlier pages; the first book’s title contains an & on purpose):

import Vapor

/// A read-only view of a book for the rendering examples on this page (page-local; not the Fluent model).
struct BookSummary: Content, Equatable {
    let id: Int
    let title: String
    let author: String
    let publishedYear: Int
    let priceCents: Int
    let rating: Int
    let status: String
}

enum Catalog {
    static let books: [BookSummary] = [
        BookSummary(id: 1, title: "Types & Programming Languages", author: "Benjamin Pierce", publishedYear: 2002, priceCents: 7450, rating: 5, status: "published"),
        BookSummary(id: 2, title: "The Swift Programming Language", author: "Apple Inc.", publishedYear: 2014, priceCents: 0, rating: 4, status: "published"),
        BookSummary(id: 3, title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", publishedYear: 2017, priceCents: 4999, rating: 5, status: "published"),
        BookSummary(id: 4, title: "Unfinished Manuscript", author: "Ada Lovelace", publishedYear: 1843, priceCents: 1250, rating: 3, status: "draft"),
    ]

    static func search(_ query: String?) -> [BookSummary] {
        guard let query = query?.trimmingCharacters(in: .whitespaces), !query.isEmpty else { return books }
        return books.filter { $0.title.localizedCaseInsensitiveContains(query) }
    }
}

The route (see the routes listing below) passes the catalog as the context, and registration is part of configure:

import Leaf
import Vapor

func configure(_ app: Application) async throws {
    app.views.use(.leaf)
    registerLeafTags(app)
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    try routes(app)
}
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>#(title)</title></head>
<body>
<h1>#(title)</h1>
<p>#greeting() - rendered at #now("yyyy") (year only, to keep the output stable)</p>
<ul>
#for(book in books):
  <li>#(book.title) by #(book.author), #price(book.priceCents) #stars(book.rating) #badge(book.status)</li>
#endfor
</ul>
<p>In euros: #price(1999, "EUR"). Unknown tag: #nosuchtag()</p>
<p>Escaped output: #unsafeBadge(note) vs #badge(note)</p>
</body>
</html>

Source: Vapor docs: Leaf getting started (app.views.use(.leaf) and req.view.render); Vapor docs: Leaf overview (the template syntax)

The response from curl (blank lines are what the #for loop leaves where its tags were; trailing whitespace is trimmed):

<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Catalog</title></head>
<body>
<h1>Catalog</h1>
<p>Hello, ada (development) - rendered at 2026 (year only, to keep the output stable)</p>
<ul>

  <li>Types &amp; Programming Languages by Benjamin Pierce, $74.50 <span class="stars" role="img" aria-label="5 out of 5">★★★★★</span> <span class="badge">published</span></li>

  <li>The Swift Programming Language by Apple Inc., $0.00 <span class="stars" role="img" aria-label="4 out of 5">★★★★☆</span> <span class="badge">published</span></li>

  <li>Designing Data-Intensive Applications by Martin Kleppmann, $49.99 <span class="stars" role="img" aria-label="5 out of 5">★★★★★</span> <span class="badge">published</span></li>

  <li>Unfinished Manuscript by Ada Lovelace, $12.50 <span class="stars" role="img" aria-label="3 out of 5">★★★☆☆</span> <span class="badge">draft</span></li>

</ul>
<p>In euros: €19.99. Unknown tag: #nosuchtag</p>
<p>Escaped output: <span class="badge"><b>hi</b></span> vs <span class="badge">&lt;b&gt;hi&lt;/b&gt;</span></p>
</body>
</html>

Five things in that output are worth reading closely:

  • $74.50, €19.99: #price returns a plain .string, which Leaf escapes like any other value, so a currency symbol that was & would appear as &.

  • The stars and the badge are real markup: #stars and #badge conform to UnsafeUnescapedLeafTag, so Leaf leaves their results alone.

  • Types & Programming Languages is the title from the context, escaped by Leaf’s normal output rule.

  • #nosuchtag() is printed as the text #nosuchtag: an unregistered tag name is not an error. A misspelled tag therefore shows up as stray text in the page. Add a rendered-page assertion to your tests for tags a page depends on.

  • The last line is the escaping demonstration: with ?note=<script>alert(1)</script> the unsafe tag emits a live <script> element, the safe one emits <script>.

<p>Escaped output: <span class="badge"><script>alert(1)</script></span> vs <span class="badge">&lt;script&gt;alert(1)&lt;/script&gt;</span></p>

The escaping rule

The serializer decides escaping by type, not by content: if the tag conforms to UnsafeUnescapedLeafTag its result is written as-is, otherwise the result goes through htmlEscaped() (which only affects string values). The consequences:

  • A tag that returns text (a date, a price, a translated string) conforms to LeafTag and gets escaping for free.

  • A tag that returns markup must conform to UnsafeUnescapedLeafTag and becomes responsible for every character it interpolates. Validate numbers (#stars accepts only an integer from 0 to 5), and escape text with a helper such as htmlEscaped in the listing (&, <, >, " and '). UnsafeBadgeTag in the listing is the same tag written wrongly, kept to show the failure.

  • The official docs advise the same: tags that render HTML use UnsafeUnescapedLeafTag, and user input is checked or sanitised. Markup built by string interpolation does not stay safe as it grows; if a tag needs more than a couple of elements, build it with a DSL (below) and render to a string inside the tag.

  • The built-in #unsafeHTML(x) is such a tag. Everything said here applies to using it on user data.

Tags with a body

A tag followed by a colon and closed with #endname receives the parsed block in ctx.body. PanelTag in the listing reads it:

A: #panel("Notes & more"):
plain <em>text</em>
#endpanel
BODY-TAG: A: <section class="panel"><h2>Notes &amp; more</h2>
plain <em>text</em>
</section>

The limit is in the type: body is [Syntax], LeafKit’s syntax tree. A literal run of text is .raw(ByteBuffer) and can be turned back into a string, but .expression, .conditional, .loop and the rest need the serializer to evaluate them, and that serializer is internal to LeafKit. With a body of #(viewer) the same tag throws LeafError.500: #panel only supports literal text in its body. In practice a tag with a body is for wrapping static text. For reusable chunks that contain variables use #extend/#export/#import (Leaf Templates page), or move that component to a DSL. Body support itself is not in the official custom-tags page; it was verified here against LeafKit 1.14.3 and may change.

Errors, threading and caching

  • A tag signals failure by throwing. A LeafError (LeafError(.unknownError("..."))) surfaces from req.view.render as LeafError.500: <message> (the output of the test that renders #stars(9)); any other error type is rethrown as it is (the output of #now("yyyy", "x") is tooManyParameters, the official example’s own error). Vapor’s ErrorMiddleware renders a non-AbortError as 500, so an author’s mistake in a template is a server error, not a 400. Outside the release environment the JSON reason of that response is the description of the error (in release it is "Something went wrong."), and the message is logged; do not put user-controlled text in it.

  • Tags are plain, non-Sendable instances shared by every request and every event loop. Keep them stateless, or make their state thread-safe (an immutable let, an actor is not usable because render is synchronous and not async). NowTag creates its formatter inside render for that reason; a hot tag can instead hold an immutable, thread-safe value created once.

  • render is synchronous and runs on the event loop that is serialising the template. Do not block in it (no file or network I/O, no .wait()), and do not call into async code. Compute asynchronous data in the handler and pass it in the context.

  • Leaf caches the parsed template, not the output. A tag therefore runs on every request, even with the cache enabled. In the .development environment Leaf disables the cache (the renderer sets cache.isEnabled = false), so template edits show up without restarting.

  • A misspelled or unregistered tag is not a startup failure; it shows as text (the #nosuchtag output above).

Type-safe HTML in Swift

A Leaf template is text in a second language that is checked when it is rendered. A DSL ("domain-specific language") moves the HTML into Swift: elements are functions or types, attributes are typed values, and the compiler checks that a <tr> is built from valid parts. Three Swift packages are worth knowing for Vapor, and they differ more than their names suggest:

Elementary + VaporElementary Plot swift-html (pointfreeco)

Style

Result builders, SwiftUI-like HTML components with a body

A Node tree built from static functions with variadic arguments, typed by context

Enum Node tree

Output

Streams chunks to the response, or .render() to a String

.render() to a String

render(_:) to a String

Also

ElementaryHTMX: typed hx-* attributes

HTML, XML, RSS, podcast and sitemap documents

Snapshot-testing helpers

Vapor integration

HTMLResponse (package VaporElementary)

none: you wrap it

none: you wrap it

Version used here

0.8.2 (tools 6.2)

0.14.0, last commit July 2024

0.5.0, last commit October 2024

Elementary is the actively developed one (release 0.8.2 on 2026-09-23, a push on 2026-10-08) and the only one with a Vapor integration and htmx support, so it gets the most space. Plot and swift-html still work with Swift 6.4 in this package, but neither has had a commit in about two years, so a new project should treat them as stable but unmaintained. None of the three is part of Vapor.

Common to all of them: the HTML is ordinary Swift, so it is refactored with functions and types, tested with plain unit tests; template syntax errors become compile errors; but there is no designer-edit-the-file workflow (Leaf’s strength), and compile times grow with deeply nested builders.

Returning a page: AsyncResponseEncodable

Vapor turns a handler’s return value into a Response through ResponseEncodable, or through the async variant, which is what a DSL page uses because it can be written without futures:

// Vapor 4.122.2
public protocol AsyncResponseEncodable: VaporSendableMetatype {
    func encodeResponse(for request: Request) async throws -> Response
}

A route closure may return any ResponseEncodable or AsyncResponseEncodable (View, Content types, String and Response are the common ones, and HTMLResponse is one). To return Plot or swift-html you therefore write a small wrapper type (shown below) or return a Response directly. Retroactively conforming a library’s own type is possible but the Swift 6 compiler asks for @retroactive and the library’s type is not guaranteed to be Sendable; a wrapper that holds the rendered String has neither problem.

Elementary and htmx

Elementary describes a page as a type. A component conforms to HTML and has a body; a whole page conforms to HTMLDocument, which adds title, head and renders the doctype and <html>. if/else, for and switch work in body because it is a result builder. Attributes are written as leading arguments (.href(...), .class(...), .id(...)), and ElementaryHTMX adds .hx.get(_:), .hx.post(_:), .hx.trigger(_:), .hx.target(_:), .hx.swap(_:) and the rest of the htmx attributes in the same style. Text is a Swift string, and Elementary escapes it.

The dependencies, pinned (the VaporElementary package declares Vapor from 4.102.0, so it resolves with 4.122.2):

// Package.swift
platforms: [.macOS(.v14)],
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
    .package(url: "https://github.com/elementary-swift/elementary.git", from: "0.8.2"),
    .package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.2"),
    .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.2"),
],
// target dependencies:
//   .product(name: "Elementary", package: "elementary"),
//   .product(name: "ElementaryHTMX", package: "elementary-htmx"),
//   .product(name: "VaporElementary", package: "vapor-elementary"),

The catalog page with a search box that asks the server for new table rows while the user types, the row fragment that both the page and the htmx request reuse, and a small component:

import Elementary
import ElementaryHTMX

struct CatalogPage: HTMLDocument {
    let books: [BookSummary]
    let query: String

    var title: String { "Bookshelf catalog" }

    var head: some HTML {
        meta(.name(.viewport), .content("width=device-width, initial-scale=1"))
        script(.src("/js/htmx.min.js")) {}
    }

    var body: some HTML {
        main {
            h1 { "Catalog" }
            input(
                .type(.search), .name("q"), .value(query), .id("q"),
                .custom(name: "aria-label", value: "Search titles"),
                .placeholder("Search titles"),
                .hx.get("/elementary/books/rows"),
                .hx.trigger(.event(.input).changed().delay("300ms")),
                .hx.target("#rows"),
                .hx.swap(.innerHTML)
            )
            table {
                thead {
                    tr { th { "Title" }; th { "Author" }; th { "Price" } }
                }
                tbody(.id("rows")) {
                    BookRows(books: books)
                }
            }
        }
    }
}

/// A reusable fragment: rendered inside the page and returned on its own for htmx.
struct BookRows: HTML {
    let books: [BookSummary]

    var body: some HTML {
        if books.isEmpty {
            tr { td(.custom(name: "colspan", value: "3")) { "No books match." } }
        } else {
            for book in books {
                BookRow(book: book)
            }
        }
    }
}

struct BookRow: HTML {
    let book: BookSummary

    var body: some HTML {
        tr(.data("book-id", value: String(book.id))) {
            td { a(.href("/books/\(book.id)")) { book.title } }
            td { book.author }
            td { Money(cents: book.priceCents) }
        }
    }
}

struct Money: HTML {
    let cents: Int

    var body: some HTML {
        span(.class("money")) { "$\(cents / 100).\(cents % 100 < 10 ? "0" : "")\(cents % 100)" }
    }
}

struct NotFoundPage: HTMLDocument {
    var title = "Not found"

    var head: some HTML {
        meta(.name(.viewport), .content("width=device-width, initial-scale=1"))
    }

    var body: some HTML {
        main {
            h1 { "No such book" }
        }
    }
}

BookRows is used twice: inside CatalogPage for the initial load and, on its own, as the response of the htmx request. That is the pattern behind htmx-style apps: the server returns fragments, and a component is the unit of reuse. The routes:

import Elementary
import Vapor
import VaporElementary

func routes(_ app: Application) throws {
    // Leaf with custom tags.
    app.get("leaf", "books") { req async throws -> View in
        struct Context: Encodable {
            let title: String
            let viewer: String
            let books: [BookSummary]
            let note: String
        }
        let note = req.query[String.self, at: "note"] ?? "<b>hi</b>"
        return try await req.view.render("catalog", Context(title: "Catalog", viewer: "ada", books: Catalog.books, note: note))
    }

    // Elementary: a full page, and the htmx fragment for the same rows.
    app.get("elementary", "books") { req async throws -> HTMLResponse in
        let query = req.query[String.self, at: "q"] ?? ""
        let books = Catalog.search(query)
        return HTMLResponse { CatalogPage(books: books, query: query) }
    }
    app.get("elementary", "books", "rows") { req async throws -> HTMLResponse in
        let books = Catalog.search(req.query[String.self, at: "q"])
        return HTMLResponse { BookRows(books: books) }
    }

    app.get("elementary", "missing") { _ async throws -> Response in
        htmlResponse(NotFoundPage(), status: .notFound)
    }

    // Plot: a String-rendering DSL wrapped in a response type.
    app.get("plot", "books") { _ async throws -> PlotPage in
        PlotPage(plotCatalog(Catalog.books))
    }

    app.get("swift-html", "books") { _ async throws -> Response in
        swiftHtmlResponse(Catalog.books)
    }
}

A helper for returning a status other than 200, discussed below (it is in its own file in the scratch package):

import Elementary
import Vapor

/// Renders to a string so that the status code can be chosen: HTMLResponse always answers 200 OK.
func htmlResponse(_ html: some HTML, status: HTTPResponseStatus = .ok) -> Response {
    Response(
        status: status,
        headers: ["Content-Type": "text/html; charset=utf-8"],
        body: .init(string: html.render())
    )
}

Real output from curl localhost:8089/elementary/books?q=swift (the long line is broken before <main> and <tbody> here), the fragment for q=zzz, and the 404 page:

$ curl -s "localhost:8089/elementary/books?q=swift"
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Bookshelf catalog</title><meta name="viewport" content="width=device-width, initial-scale=1"><script src="/js/htmx.min.js"></script></head><body>
<main><h1>Catalog</h1><input type="search" name="q" value="swift" id="q" aria-label="Search titles" placeholder="Search titles" hx-get="/elementary/books/rows" hx-trigger="input changed delay:300ms" hx-target="#rows" hx-swap="innerHTML"><table><thead><tr><th>Title</th><th>Author</th><th>Price</th></tr></thead>
<tbody id="rows"><tr data-book-id="2"><td><a href="/books/2">The Swift Programming Language</a></td><td>Apple Inc.</td><td><span class="money">$0.00</span></td></tr></tbody></table></main></body></html>

$ curl -s "localhost:8089/elementary/books/rows?q=zzz"
<tr><td colspan="3">No books match.</td></tr>

$ curl -si localhost:8089/elementary/missing
HTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
content-length: 202

<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Not found</title><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><main><h1>No such book</h1></main></body></html>

Details that matter in practice:

  • HTMLResponse takes a builder closure: HTMLResponse { CatalogPage(...) }. The page value must be Sendable (the struct above is, because its stored properties are), or on macOS 15 and later (and Linux) the sending overload takes non-Sendable values that are used once. A non-Sendable page can be written only once, and a second write is a runtime error (assertionFailure in a debug build).

  • The response is streamed: transfer-encoding: chunked, 1024-byte chunks by default (chunkSize), content type text/html; charset=utf-8. The status line and headers are sent before the body is finished, so an error thrown while rendering the middle of a page cannot become a 500 page any more; the connection ends early. Do the database work in the handler and pass the results in, so that body only reads values.

  • HTMLResponse always answers 200 OK and exposes headers and chunkSize but no status. To return another status, render to a string and build the Response yourself, as htmlResponse(_:status:) in the listing does (the 404 output above). Because that variant buffers the whole page, use it for small pages such as error pages.

  • Text and attribute values are escaped. In the check below <script> in text became <script>; in a double-quoted attribute the check above shows only " escaped ("), which is enough inside a quoted attribute. HTMLRaw writes a string unescaped and is the DSL’s equivalent of #unsafeHTML.

  • Elementary does not know every attribute: td has no colspan helper in 0.8.2, so the listing uses .custom(name:value:).

  • htmx itself is a JavaScript file you serve (here Public/js/htmx.min.js, a placeholder in the scratch package). Vendor a pinned version and load it from your own origin, or from a CDN with a Subresource Integrity hash. ElementaryHTMX only writes the attributes.

  • A fragment endpoint is an ordinary route and needs the same authentication, authorisation and CSRF handling as the page (see Server-Rendered Web Apps and Forms). An htmx request carries an HX-Request: true header; use it to return the fragment or the whole page from the same route, which keeps a link working when JavaScript is off.

Plot

Plot builds a tree of Node values whose type parameter is the context (HTML.BodyContext for the content of <body>, for example), so a list item can only be used where a list is expected. It has no component protocol: you compose with functions that return `Node`s.

import Plot
import Vapor

/// Plot renders to a String, so the response wrapper keeps the rendered text (a Sendable value).
struct PlotPage: AsyncResponseEncodable {
    let html: String

    init(_ document: Plot.HTML) {
        self.html = document.render()
    }

    func encodeResponse(for request: Request) async throws -> Response {
        Response(status: .ok, headers: ["Content-Type": "text/html; charset=utf-8"], body: .init(string: html))
    }
}

func plotCatalog(_ books: [BookSummary]) -> Plot.HTML {
    Plot.HTML(
        .lang(.english),
        .head(
            .title("Bookshelf catalog"),
            .meta(.charset(.utf8))
        ),
        .body(
            .h1("Catalog"),
            .ul(
                .forEach(books) { book in
                    .li(.a(.href("/books/\(book.id)"), .text(book.title)), .text(" by \(book.author)"))
                }
            )
        )
    )
}

The listing’s PlotPage is the wrapper described above. Plot.HTML is spelled with its module name because the Elementary HTML protocol is in the same package; if your target imports both libraries, qualify every ambiguous name or keep each library in its own file. Output of curl localhost:8089/plot/books:

<!DOCTYPE html><html lang="en"><head><title>Bookshelf catalog</title><meta name="twitter:title" content="Bookshelf catalog"/><meta property="og:title" content="Bookshelf catalog"/><meta charset="UTF-8"/></head><body><h1>Catalog</h1><ul><li><a href="/books/1">Types &amp; Programming Languages</a> by Benjamin Pierce</li><li><a href="/books/2">The Swift Programming Language</a> by Apple Inc.</li><li><a href="/books/3">Designing Data-Intensive Applications</a> by Martin Kleppmann</li><li><a href="/books/4">Unfinished Manuscript</a> by Ada Lovelace</li></ul></body></html>

Plot added twitter:title and og:title meta elements by itself when given a .title.

swift-html

pointfreeco’s library models a document as an enum Node and renders it with render(_:). It is the smallest of the three, and it does not conform to anything in Vapor.

import Html
import Vapor

func swiftHtmlCatalog(_ books: [BookSummary]) -> Node {
    .document(
        .html(
            .head(.title("Bookshelf catalog")),
            .body(
                .h1("Catalog"),
                .ul(
                    .fragment(books.map { book in
                        .li(.a(attributes: [.href("/books/\(book.id)")], .text(book.title)), " by \(book.author)")
                    })
                )
            )
        )
    )
}

func swiftHtmlResponse(_ books: [BookSummary]) -> Response {
    let html = render(swiftHtmlCatalog(books))
    return Response(status: .ok, headers: ["Content-Type": "text/html; charset=utf-8"], body: .init(string: html))
}
<!DOCTYPE html><html><head><title>Bookshelf catalog</title></head><body><h1>Catalog</h1><ul><li><a href="/books/1">Types &amp; Programming Languages</a> by Benjamin Pierce</li><li><a href="/books/2">The Swift Programming Language</a> by Apple Inc.</li><li><a href="/books/3">Designing Data-Intensive Applications</a> by Martin Kleppmann</li><li><a href="/books/4">Unfinished Manuscript</a> by Ada Lovelace</li></ul></body></html>

swift-html declares its own Application type, so a file that imports both Html and Vapor cannot write Application without qualification (the compiler reports it as ambiguous). The listing keeps the Html import in a file that only needs Response, and the routes.swift listing above does not import it.

Escaping: what each library does

The scratch package’s test renders the same hostile string through each library:

    @Test func dslEscaping() {
        let hostile = "<script>alert(\"x\")</script>"
        print("ELEMENTARY text:", p { hostile }.render())
        print("ELEMENTARY attr:", a(.href("/x?q=\(hostile)"), .title(hostile)) { "go" }.render())
        print("ELEMENTARY raw:", p { HTMLRaw(hostile) }.render())
        print("PLOT text:", Plot.Node<Plot.HTML.BodyContext>.p(.text(hostile)).render())
        print("PLOT raw:", Plot.Node<Plot.HTML.BodyContext>.p(.raw(hostile)).render())
        print("PLOT attr:", Plot.Node<Plot.HTML.BodyContext>.a(.href("/x\"onclick=\"y"), .text("go")).render())
    }
ELEMENTARY text: <p>&lt;script&gt;alert("x")&lt;/script&gt;</p>
ELEMENTARY attr: <a href="/x?q=<script>alert(&quot;x&quot;)</script>" title="<script>alert(&quot;x&quot;)</script>">go</a>
ELEMENTARY raw: <p><script>alert("x")</script></p>
PLOT text: <p>&lt;script&gt;alert("x")&lt;/script&gt;</p>
PLOT raw: <p><script>alert("x")</script></p>
PLOT attr: <a href="/x"onclick="y">go</a>
  • Text: Elementary and Plot’s .text both escape < and > and leave " in text alone, which is correct for element content. Plot’s .raw and Elementary’s HTMLRaw do not escape. swift-html is not part of this comparison.

  • Attributes: Elementary escapes " in attribute values. Plot 0.14.0 did not: a value containing a double quote ended the attribute and injected another (href="/x"onclick="y" above). Never put request data into a Plot attribute without escaping the quote yourself or validating the value; this was observed on 0.14.0 and the library is no longer maintained, so do not expect a fix. Treat URLs from users (href, src) separately in every library: no DSL checks the scheme, and javascript: URLs are still yours to reject.

  • Because none of these is a sanitiser, user-supplied HTML (rich text, Markdown output) needs a sanitiser before it reaches HTMLRaw, .raw or #unsafeHTML.

Choosing between Leaf, a DSL and a separate front end

flowchart TD Q1{Do designers or non-Swift developers edit the markup?} Q1 -->|yes| LEAF[Leaf with a few custom tags] Q1 -->|no| Q2{Is the UI mostly pages with forms and partial updates?} Q2 -->|yes| Q3{Do you want components and compile-time checks?} Q3 -->|yes| EL[Elementary with htmx] Q3 -->|no| LEAF Q2 -->|no| Q4{Rich client-side interactivity or a mobile app too?} Q4 -->|yes| SPA[JSON API in Vapor and a separate front end] Q4 -->|no| LEAF
Approach Strengths Costs Fits

Leaf (plus custom tags)

Plain HTML files, edited without compiling; hot reload in development; the best-documented option; layouts with #extend

Errors appear at render time; unknown tags print as text; logic belongs in handlers and tags

Content sites, admin pages, anything a template author maintains

A DSL (Elementary best supported)

Compile-time checks, components, refactoring, unit-testable fragments, typed htmx attributes, streaming output

No designer workflow; a recompile for every markup change; the smaller ecosystem and bus factor

Server-rendered apps built by Swift developers, htmx fragments

Separate front end (React, Vue, SwiftUI or UIKit apps, with the Vapor API in JSON)

Rich interactivity, one API for web and mobile

Two codebases, an API contract to maintain, CORS and token handling

Applications with a mobile client or heavy client-side state

Mixing is normal: Leaf for the page frame and layout, a custom tag or DSL component for the pieces where Leaf is awkward (a form field with error text, a pagination bar). A tag can render a DSL component to a string and return it from an UnsafeUnescapedLeafTag, because the DSL has already escaped its own output. Whichever you choose, the security headers, sessions and CSRF protection are properties of the middleware stack and apply to all of them, and a user-visible page must stay accessible: label inputs, keep a skip link and a logical heading order, as Web accessibility describes.

Testing rendered output

Both Leaf and the DSLs are tested through the application: app.test(.GET, "path") returns the Response, and a body assertion catches a misspelled tag or a changed attribute. Components that do not need a request are tested without one, by calling .render() and comparing the string.

import Elementary
import Plot
import Testing
import Vapor
import VaporTesting
@testable import App

@Suite("Rendering")
struct RenderingTests {
    private func withApp(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test func leafBadStarsFails() async throws {
        try await withApp { app in
            struct Empty: Encodable {}
            do {
                _ = try await app.view.render("bad-stars", Empty())
                Issue.record("expected an error")
            } catch {
                print("BAD-STARS:", String(describing: error))
            }
            do {
                _ = try await app.view.render("bad-now", Empty())
                Issue.record("expected an error")
            } catch {
                print("BAD-NOW:", String(describing: error))
            }
            struct Ctx: Encodable { let viewer = "ada" }
            do {
                let view = try await app.view.render("body-tag", Ctx())
                print("BODY-TAG:", String(buffer: view.data))
            } catch {
                print("BODY-TAG ERR:", String(describing: error))
            }
        }
    }

    @Test func notFoundStatus() async throws {
        try await withApp { app in
            try await app.test(.GET, "elementary/missing") { res async in
                #expect(res.status == .notFound)
                print("MISSING:", res.body.string)
            }
        }
    }
}

For Leaf, app.view.render("template", context) renders without a Request, as the scratch tests did to capture the error messages above (the bad-stars.leaf template contains #stars(9), bad-now.leaf contains #now("yyyy", "x"), and body-tag.leaf is the panel template shown earlier). Output of the test run:

BAD-STARS: LeafError.500: #stars(rating) expects an integer between 0 and 5
MISSING: <!DOCTYPE html><html><head><meta charset="UTF-8"><title>Not found</title><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><main><h1>No such book</h1></main></body></html>
BAD-NOW: tooManyParameters
BODY-TAG: A: <section class="panel"><h2>Notes &amp; more</h2>
plain <em>text</em>
</section>

The DSL libraries also ship snapshot helpers (swift-html’s HtmlSnapshotTesting target is a separate product), which are not covered here.

Common mistakes

Symptom Cause Fix

#mytag printed in the page

tag not registered, or registered under another name

register before the first request; assert on the rendered body

<script> from user text runs

markup tag conforms to UnsafeUnescapedLeafTag and interpolates raw input

escape with a helper, or build with a DSL; or return a plain LeafTag string

<span> appears literally

markup tag conforms to plain LeafTag

conform to UnsafeUnescapedLeafTag

Compile error Type 'X' does not conform to 'Sendable' with HTMLResponse

the page holds a non-Sendable value

store Sendable values (structs of strings, numbers), or use the sending overload on macOS 15+

'Application' is ambiguous for type lookup

a file imports Html (swift-html) and Vapor

keep Html in its own file or write Vapor.Application

HTML is ambiguous

a file imports Elementary and Plot

qualify Plot.HTML, or split the files

Status is always 200 with Elementary

HTMLResponse has no status

render to a string and build the Response, as above

Truncated page and a dropped connection

error thrown while a streamed page was being rendered

load data in the handler before building the page

A tag with a body throws

expressions inside the body are not evaluable by a custom tag

keep bodies static or use #extend/#export

Slow requests under load

a tag performs I/O in render

compute in the handler, pass in the context

Elementary or VaporElementary fails to resolve

the package’s platform is below macOS 14

platforms: [.macOS(.v14)]

User-controlled URL in href runs script

no DSL validates the scheme

allow only http, https and relative URLs

Version notes

  • Vapor 3 and earlier / Leaf 1 to 3. The custom tag protocol was TagRenderer with render(tag: TagContext) returning a future of TemplateData, registered through a LeafTagConfig. Leaf 4 replaced it by LeafTag.render(_:) returning LeafData synchronously, registered in app.leaf.tags.

  • Leaf 4.x (current, 4.5.2). The protocol shape described here has been stable for the 4.x line. LeafContext.application comes from Leaf, not LeafKit.

  • Vapor 5 (5.0.0-beta.4, not generally available). vapor/leaf-kit has a v5 branch (last commit 2026-04-28) whose LeafTag and UnsafeUnescapedLeafTag have the same declarations as 1.14.3, but no leaf release targets Vapor 5 yet, so Leaf’s own Vapor 5 integration is still open. The DSL packages above target Vapor 4: VaporElementary’s Package.swift pins Vapor from: "4.102.0" and its HTMLResponse is written against AsyncResponseEncodable; check the packages' release notes before moving a DSL-based app to Vapor 5.

  • Swift 6. LeafTag is not Sendable, which is why registering instances in a dictionary compiles without annotations; stored state in a tag is a data race you must prevent yourself.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco): its Leaf chapters (14 and 15, per the section’s concept table) are still on Vapor 3, where views are rendered through futures and a custom tag is a TagRenderer. In Vapor 4 and Leaf 4 the tag is a LeafTag registered in app.leaf.tags, rendering is try await req.view.render(...), and the layout directives are #extend/#export/#import. HTML DSLs are not part of the book.

  • Yeung (Packt) builds its web UI on Leaf 3 partials; the Leaf 4 syntax and built-in tag set differ (see the Leaf Templates page).

  • Patel (Packt) is on Vapor 2 and its Leaf 2; Leaf of that generation registered tags through its own Tag API on the stem, and none of that exists in Leaf 4.

  • None of the three books covers Elementary, Plot or swift-html, so the DSL half of this page rests on the libraries' own documentation and on the scratch-package runs. The books were not to hand while this page was written, so these remarks come from the frameworks' version history, not from rereading the chapters.