Leaf Templates

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 (released 2026-06-27) and LeafKit 1.14.3, 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; every template shown was rendered by that package, the output shown is captured from curl against the running service and from a VaporTesting suite, and the statements about what Leaf accepts and rejects were checked by rendering small probe templates, not only by reading the documentation. Leaf has no release for Vapor 5 yet: at the time of writing the main branch of vapor/leaf still depends on Vapor 4.x, and Vapor 5 is at 5.0.0-beta.4 and not released. The page extends the Bookshelf service of the earlier pages of this section with its public catalogue: HTML pages for browsers next to the JSON API. HTML and CSS themselves are in the site’s HTML & CSS section (see in particular HTML structure and forms), and accessible markup in Accessibility. Swift itself is covered by the Swift Reference; the same job in Rust is done in Server-side Templates with askama. The Leaf Custom Tags and HTML DSLs page (custom Leaf tags and type-safe HTML in Swift) and the Server-Rendered Web Apps and Forms page (complete form-driven pages, sessions and CSRF) build on this page.

What Leaf is and where it runs

Leaf is Vapor’s templating language. A template is a text file (usually HTML) with tags that begin with #; Leaf parses it once into a syntax tree, and rendering walks that tree with a context of values and produces bytes. Two packages are involved, and the split explains several error messages:

  • LeafKit (vapor/leaf-kit, 1.14.3) is the language: lexer, parser, serializer, the built-in tags, the cache and the template sources. It does not depend on Vapor.

  • Leaf (vapor/leaf, 4.5.2) is the integration: the app.leaf accessor, .leaf as a Application.Views.Provider, the encoder that turns an Encodable context into Leaf data, the req.leaf renderer, and the conformance of LeafError to AbortError so that a failed render becomes a 500 response.

In the request lifecycle (middleware, router, handler, response encoding) rendering happens inside the handler:

flowchart LR A["Request"] --> B["Middleware chain"] B --> C["Router"] C --> D["Handler: build context, await req.view.render"] D --> E["LeafRenderer: load or cache AST, serialize with context"] E --> F["View returned by the handler"] F --> G["Response encoding: 200, text/html; charset=utf-8"] G --> H["Middleware chain on the way out"] E -. "LeafError: HTTP 500 through ErrorMiddleware" .-> B
  • The handler builds a context value, awaits req.view.render(_:_:) and returns the resulting View. View is ResponseEncodable; encoding it gives 200 OK with content-type: text/html; charset=utf-8 (verified with curl -I).

  • req.view asks the configured ViewRenderer for a renderer for this request. For Leaf that is req.leaf, a LeafRenderer built per access from the application’s configuration, tags, cache and sources, with the request itself stored in its userInfo. A custom tag can therefore read ctx.request and ctx.application.

  • A LeafError (missing template, lexer error, a tag throwing) conforms to AbortError with status 500, so ErrorMiddleware answers with it. Section Errors you will meet shows the messages, and why you should not let them reach a browser in production.

  • Because the view is rendered in the handler, middleware that wraps the route (security headers, compression, request logging) sees a finished HTML body.

Installing Leaf and configuring Vapor

Leaf is a separate package (the official vapor/template lists it and ships Resources/Views/index.leaf). Add the package and the product, and, if you want to touch LeafConfiguration or LeafSources in your own code (see Configuration, the cache and template sources), the LeafKit product as well, because Leaf re-exports only a few LeafKit types (LeafTag, UnsafeUnescapedLeafTag, LeafData, LeafContext, Syntax):

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "Bookshelf",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
        .package(url: "https://github.com/vapor/leaf.git", from: "4.5.2"),
        .package(url: "https://github.com/vapor/leaf-kit.git", from: "1.14.3"),
    ],
    targets: [
        .executableTarget(name: "App", dependencies: [
            .product(name: "Vapor", package: "vapor"),
            .product(name: "Leaf", package: "leaf"),
            .product(name: "LeafKit", package: "leaf-kit"),
        ]),
    ]
)

Source: Vapor docs: Leaf, getting started (the page shows from: "4.4.0"; the scratch package pins 4.5.2 exactly). The Fluent packages of the earlier pages are omitted from this abridged manifest.

One line in configure.swift switches the application’s view renderer to Leaf:

import Fluent
import FluentSQLiteDriver
import Leaf
import Vapor

func configure(_ app: Application) async throws {
    app.databases.use(.sqlite(.memory), as: .sqlite)
    app.migrations.add(CreateBookshelf())
    try await app.autoMigrate()

    // Leaf: make req.view render with Leaf. Templates come from Resources/Views.
    app.views.use(.leaf)

    try routes(app)
}

(The scratch package also runs the Bookshelf migration with in-memory SQLite, as in the Fluent pages, because the catalogue route below reads books.) The facts that matter:

  • app.views.use(.leaf) registers a provider; req.view then uses Leaf. Without it, req.view falls back to Vapor’s PlaintextRenderer (installed by Application at start-up), which reads the file and does not understand tags, so the page would come out as the raw template. Call it before the first request; it is part of configure, not of a route.

  • Templates are read from Resources/Views relative to the working directory (app.directory.viewsDirectory, which is app.directory.resourcesDirectory + "Views/"). A name is a path below that directory without the .leaf extension: books/index is Resources/Views/books/index.leaf. The file source is sandboxed to the views directory; a name such as ../secret did not resolve (No template found) in a probe.

  • Static files (CSS, JavaScript, images) are not templates and do not go in Resources/Views; they go in Public/ and are served by FileMiddleware, covered in the Static Files and File I/O page.

  • When the app is started from Xcode the working directory is not the package root, so Leaf cannot find Resources/Views and every render fails with a "No template found" error. Set a custom working directory in the scheme (Run, Options, "Use custom working directory"), or start the server with swift run from the package directory.

Rendering a view

The route is an ordinary async handler. The context is a value you build; Leaf reads it through its Encodable conformance:

import Fluent
import Vapor

/// One row of the catalogue, shaped for the template: strings are already formatted.
struct BookRow: Content {
    let title: String
    let isbn: String
    let year: Int
    let price: String
    let status: BookStatus
    let tags: [String]
}

struct CatalogueContext: Encodable {
    let title: String
    let user: String?
    let books: [BookRow]
    let generatedAt: Date
}

struct BookDetailContext: Encodable {
    let title: String
    let book: BookRow
    let summaryHTML: String
    let reviews: [String]
}

extension BookRow {
    /// Maps a Fluent model whose `author` is not needed here; `tags` must be eager loaded.
    init(_ book: Book) throws {
        self.init(
            title: book.title,
            isbn: book.isbn,
            year: book.publishedYear,
            price: String(format: "%d.%02d", book.priceCents / 100, book.priceCents % 100),
            status: book.status,
            tags: book.tags.map(\.name)
        )
    }
}

Source: Vapor docs: Leaf, overview (context); the Fluent part follows Vapor docs: Fluent relations.

import Fluent
import Leaf
import Vapor

func routes(_ app: Application) throws {
    app.get("books") { req async throws -> View in
        let books = try await Book.query(on: req.db)
            .filter(\.$status == .published)
            .sort(\.$title)
            .with(\.$tags)
            .all()
        let context = CatalogueContext(
            title: "Bookshelf catalogue",
            user: req.query[String.self, at: "user"],
            books: try books.map(BookRow.init),
            generatedAt: Date()
        )
        return try await req.view.render("books/index", context)
    }

    // A fixed example so the output on the page is reproducible without a database.
    app.get("books", "demo") { req async throws -> View in
        let context = CatalogueContext(
            title: "Bookshelf catalogue",
            user: req.query[String.self, at: "user"],
            books: [
                BookRow(title: "Dune", isbn: "9780441013593", year: 1965, price: "9.99", status: .published, tags: ["sci-fi", "classic"]),
                BookRow(title: "Tom & Jerry <Annual>", isbn: "9780000000002", year: 2001, price: "12.50", status: .draft, tags: []),
                BookRow(title: "Emma", isbn: "9780141439587", year: 1815, price: "7.99", status: .archived, tags: ["classic"]),
            ],
            generatedAt: Date(timeIntervalSince1970: 1_760_000_000)
        )
        return try await req.view.render("books/index", context)
    }

    app.get("books", "dune") { req async throws -> View in
        let context = BookDetailContext(
            title: "Dune",
            book: BookRow(title: "dune", isbn: "9780441013593", year: 1965, price: "9.99", status: .published, tags: ["sci-fi"]),
            summaryHTML: "<em>Trusted</em> editor-written summary",
            reviews: ["Great", "<script>alert(1)</script>"]
        )
        return try await req.view.render("books/show", context)
    }
}

What to take from this:

  • Context rules. render(_:_:) takes some Encodable (the older [String: String] dictionary literal such as req.view.render("hello", ["name": "Leaf"]) is only the simplest case). A struct is preferred: its property names become the variable names in the template. A dictionary of mixed values ([String: Any]) does not work, and the top-level value must be a keyed container: to pass an array, wrap it in a struct, as CatalogueContext.books does.

  • What the encoder produces. Strings, numbers, booleans, arrays, dictionaries and nested structs map to the obvious Leaf values. An enum with a String raw value (BookStatus) becomes its raw value, so book.status == "published" works in a template. A Date becomes a number of seconds since 1970, which is what #date expects. nil optionals leave the variable undefined, not an error (see Variables and expressions).

  • Prefer a view-model over the Fluent model. A Fluent model is Codable, and handing it to Leaf works, but the template would then see every field the model encodes (including passwordHash on User) and would silently see nothing for a relation you forgot to load with .with(\.$tags). BookRow is the template’s contract: it holds already formatted strings (a price is formatted in Swift, not computed in the template), and the mapping is the one place that must eager-load tags.

  • Swift 6 concurrency. The context is created and consumed inside one handler task, so it does not need to be Sendable for render to accept it; Content structs are Sendable anyway. The canonical Fluent models are @unchecked Sendable classes in these pages; copying them into value-type view-models before rendering avoids sharing mutable model instances with the renderer. req.view is awaited, so the handler is suspended, not blocked, while the template file is read (through NonBlockingFileIO) and the output is serialized.

  • The result is a View. View.data is the rendered ByteBuffer. To get a String (for an e-mail body, say) use String(buffer: view.data), as renderToString does in Configuration, the cache and template sources.

The Bookshelf templates

A layout, two partials and two pages. The layout is the skeleton of every HTML page and declares three import points; a page extends it and exports content for those points:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>#import("title") - Bookshelf</title>
  #import("head")
</head>
<body>
  #extend("partials/nav")
  <main>
    #import("content")
  </main>
  #extend("partials/footer")
</body>
</html>
<nav>
  <a href="/books">Catalogue</a>
  #if(user):
    <span>Signed in as #(user)</span>
  #else:
    <a href="/login">Sign in</a>
  #endif
</nav>
<footer>Bookshelf demo</footer>
#extend("base"):
  #export("title", title)
  #export("content"):
    <h1>#(title)</h1>
    #comment("Rendered #count(books) books")
    <p>#count(books) books, generated #date(generatedAt, "yyyy-MM-dd HH:mm", "UTC").</p>
    #if(count(books) == 0):
      <p>No books.</p>
    #else:
      <table>
        <thead><tr><th>#</th><th>Title</th><th>ISBN</th><th>Price</th><th>Status</th><th>Tags</th></tr></thead>
        <tbody>
        #for(i, book in books):
          <tr class="#if(isFirst):first#endif #if(isLast):last#endif #if(i % 2 == 0):even#else:odd#endif">
            <td>#(i + 1)</td>
            <td><a href="/books/#(book.isbn)">#(book.title)</a></td>
            <td>#(book.isbn)</td>
            <td>#(book.price)</td>
            <td>#uppercased(book.status)</td>
            <td>#if(count(book.tags) > 0):#for(tag in book.tags):#(tag)#if(!isLast):, #endif#endfor#else:-#endif</td>
          </tr>
        #endfor
        </tbody>
      </table>
    #endif
  #endexport
#endextend

The structure, as a diagram of which template provides what:

flowchart TB subgraph child["books/index.leaf (the page)"] X1["export title"] X2["export content"] end subgraph base["base.leaf (the layout)"] I1["import title in the head"] I2["import head"] P1["extend partials/nav"] I3["import content in main"] P2["extend partials/footer"] end child -- "extend base" --> base X1 --> I1 X2 --> I3 I2 -.- NOTE["no export: renders nothing"] P1 --> N["nav.leaf: sees the same context"] P2 --> F["footer.leaf: sees the same context"]

The result of rendering books/index with /books/demo?user=ana (blank lines trimmed; Leaf keeps the whitespace of the lines that hold only tags):

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bookshelf catalogue - Bookshelf</title>
</head>
<body>
  <nav>
  <a href="/books">Catalogue</a>
    <span>Signed in as ana</span>
</nav>
  <main>
    <h1>Bookshelf catalogue</h1>
    <p>3 books, generated 2025-10-09 08:53.</p>
      <table>
        <thead><tr><th>#</th><th>Title</th><th>ISBN</th><th>Price</th><th>Status</th><th>Tags</th></tr></thead>
        <tbody>
          <tr class="first  even">
            <td>1</td>
            <td><a href="/books/9780441013593">Dune</a></td>
            <td>9780441013593</td>
            <td>9.99</td>
            <td>PUBLISHED</td>
            <td>sci-fi, classic</td>
          </tr>
          <tr class="  odd">
            <td>2</td>
            <td><a href="/books/9780000000002">Tom &amp; Jerry &lt;Annual&gt;</a></td>
            <td>9780000000002</td>
            <td>12.50</td>
            <td>DRAFT</td>
            <td>-</td>
          </tr>
          <tr class=" last even">
            <td>3</td>
            <td><a href="/books/9780141439587">Emma</a></td>
            <td>9780141439587</td>
            <td>7.99</td>
            <td>ARCHIVED</td>
            <td>classic</td>
          </tr>
        </tbody>
      </table>
  </main>
  <footer>Bookshelf demo</footer>
</body>
</html>

Things to notice in that output: the title with & and < is escaped; #(i + 1) did arithmetic; isFirst and isLast produced the first and last classes; the head import, which no page exported, rendered nothing and raised no error; the partials saw the page’s context, so user is available in nav.leaf. The second page uses the tags that deal with trust and debugging:

#extend("base"):
  #export("title", title)
  #export("head"):
    <meta name="robots" content="noindex">
  #endexport
  #export("content"):
    <h1>#capitalized(book.title)</h1>
    <div class="summary">#unsafeHTML(summaryHTML)</div>
    <div class="escaped">#(summaryHTML)</div>
    <ul>
    #for(review in reviews):
      <li>#(review)</li>
    #endfor
    </ul>
    <pre>#dumpContext</pre>
  #endexport
#endextend

Output of /books/dune (the context holds title: "dune", a summary containing markup and a review containing a <script> element; the #dumpContext output is shortened):

    <h1>Dune</h1>
    <div class="summary"><em>Trusted</em> editor-written summary</div>
    <div class="escaped">&lt;em&gt;Trusted&lt;/em&gt; editor-written summary</div>
    <ul>
      <li>Great</li>
      <li>&lt;script&gt;alert(1)&lt;/script&gt;</li>
    </ul>
    <pre>[book: &quot;[isbn: &quot;9780441013593&quot;, price: &quot;9.99&quot;, status: &quot;published&quot;, ...

The tag syntax

A tag has up to four parts: the tag indicator #, a name, a parameter list in parentheses, and an optional body that starts after a colon and ends with an #end... tag. Everything outside a tag is copied to the output unchanged, which is why the CSS braces and JavaScript braces of a page need no escaping in Leaf 4: bodies are introduced by :, not by {. A # that does not start a tag (<a href="#top">, color: #fff, a CSS selector #main {) is also left as it is; to write a literal tag name or to protect text that would otherwise parse as a tag, escape the indicator with a backslash: \#42 renders as #42.

Variables and expressions

#(name) prints a variable; the dotted path #(book.title) reaches into nested structs and dictionaries. Inside the parentheses of any tag you can write an expression with these operators, all verified by rendering:

Operators Notes

== != < <= > >=

Comparison; strings compare with strings (book.status == "published").

&& || !

Logical and, or, not; ! applies to a single value (!flag, !isLast).

+ - * / %

Arithmetic. / on two integers is integer division (7 / 2 gives 3, 7.0 / 2 gives 3.5); + also joins strings.

( )

Grouping.

Behaviours worth knowing before you debug them:

  • An undefined variable (#(nothing)), a missing nested key (#(book.nothing)) and a nil optional all print nothing; none is an error. A misspelled variable name is therefore visible only as a gap in the page. In a condition they are false, so #if(user): is the "is it present" test (but see the truthiness note under conditions).

  • A Bool prints true or false, a Double prints like 2.5, an array prints as a quoted list (["a", "b", "c"], HTML-escaped), a Date prints as its number of seconds.

  • Subscripts (items[0]) and the nil-coalescing operator ?? are rejected by the lexer in LeafKit 1.14.3, and so is assignment: #let(x = 5) fails with "= is not yet supported as an operator". Leaf 4 has no local variables; do the computation in Swift.

  • Inside the parentheses of another tag, a built-in tag is written without the #: #if(count(books) > 0):, #if(contains(tags, "classic")):.

Conditions: if, elseif, else

#if(count(books) == 0):
  <p>No books.</p>
#elseif(count(books) < 5):
  <p>A few books.</p>
#else:
  <p>Many books.</p>
#endif

Truthiness is narrower than in JavaScript or Python, and this is the most common surprise. Rendering showed that #if(x): is false only for false, nil and an undefined variable; it is true for the number 0, the empty string "" and an empty array. So #if(books): does not test for emptiness: write #if(count(books) > 0):, #if(name != ""): for strings and #if(n != 0): for numbers. The keywords true, false, nil, yes, no and self are available inside expressions.

Loops: for

<ul>
#for(book in books):
  <li>#(book.title)</li>
#endfor
</ul>

#for(i, book in books):
  <li>#(i + 1). #(book.title)#if(isLast): (last)#endif</li>
#endfor

Inside the body of a #for, three variables describe the iteration: isFirst and isLast (booleans) and index (the zero-based position). In the first form the index is called index; in the second form you pick the name (i above) and index is not set. The official overview documents only the indexed form; isFirst and isLast are set by the serializer (LeafSerializer) for every loop. The loop variables of the inner loop shadow those of the outer one: in the catalogue template the comma between tags uses #if(!isLast):, #endif inside the inner loop, which tests the inner list, while the first/last row classes are tested in the outer loop body before the inner loop starts. The array may be a dotted path (#for(x in nested.k):). Looping over something that is not an array fails with expected array at key: n; the serializer requires an array, so a dictionary cannot be looped over directly.

Layouts: extend, export, import

#extend("base"):
  #export("title", title)
  #export("content"):
    <h1>Hello</h1>
  #endexport
#endextend
  • #extend("base"): …​ #endextend renders Resources/Views/base.leaf with the exports of its body available to its #import tags. The name has no extension (the renderer accepted base.leaf as well, but the documentation says to omit it). A name with directories is a path below the views directory (partials/nav).

  • #export("name"): ... #endexport stores a block; #export("title", title) is the short form that exports a single expression, convenient for a page title.

  • #import("name") in the layout prints the block exported under that name. An import with no matching export prints nothing and is not an error: that is the ignoreUnfoundImports setting of LeafConfiguration, which is true by default (verified with #import("nothing")).

  • #extend("footer") with no body is an include: it pastes the other template at that point, with the current context. This is what partials/nav and partials/footer are.

  • Exports and includes share the page’s context: user, books and title are visible in the layout and in the partials. There is no syntax in 4.5 for passing different values to an include (#extend("x", ["k": v]) does not lex); put what the partial needs in the context.

  • A template that is its own ancestor fails with a "cyclically referenced" error (from the LeafKit source; not rendered here).

with

#with(book): ... #endwith runs its body with the keys of the dictionary book promoted to the top level, so #with(book):#(title)/#(year)#endwith prints Dune/1965. It is implemented in LeafKit (Syntax.With) and verified by rendering, but the official overview does not document it.

Built-in tags

Tag Meaning Verified behaviour

#count(x)

Number of elements

Arrays and dictionaries; anything else throws.

#lowercased(s), #uppercased(s), #capitalized(s)

Case conversion (capitalized uses String.capitalized)

#capitalized(book.title) with dune prints Dune; a non-string parameter throws.

#contains(array, value)

True when the array holds the value

#contains(items, "b") prints true; the first parameter must be an array.

#date(d), #date(d, "format"), #date(d, "format", "Time/Zone")

Formats a number of seconds since 1970 with a DateFormatter pattern

With one parameter the pattern is yyyy-MM-dd'T'HH:mm:ss in the server’s time zone; pass a time zone identifier ("UTC") to get reproducible output.

#isEmpty(s)

True when a string is empty

Throws "unable to check for empty value; cannot convert parameter to string" when given an array. Use count(items) == 0 for collections.

#unsafeHTML(s)

Prints the string without HTML escaping

See Escaping and XSS.

#comment("text")

Prints nothing; the text may span lines with a triple-quoted string

The text is not parsed for tags.

#dumpContext

The whole context as one value

Takes no parameters; its output is escaped like any other. Remove it before deploying: it prints every context value, including ones you did not mean to show.

These ten tags, plus the control-flow tags (if, elseif, else, for, with, extend, export, import), are all that exist; there is no #set/#get, no #embed, no #loop, no #raw, no #min/#max. Anything else is a custom tag (the Leaf Custom Tags and HTML DSLs page) or Swift code in the handler.

A probe template that exercises most of them, with its real output:

name=[#(name)] missing=[#(nothing)] nested=[#(book.nothing)]
not=[#if(!flag):yes#endif] and=[#if(n > 3 && !flag):yes#endif] or=[#if(flag || n == 7):yes#endif]
math=[#(n * 2 + 1)] mod=[#(n % 4)] price=[#(price)] int=[#(n)] bool=[#(flag)]
items=[#(items)] count=[#count(items)] contains=[#contains(items, "b")]
with:#with(book):[#(title)/#(year)]#endwith
nestedloop:#for(x in nested.k):#(x)#(isLast)#endfor
#for(i, item in items):#(i)#(item)#if(isFirst):F#endif#if(isLast):L#endif|#endfor

with the context name: nil, n: 7, items: ["a", "b", "c"], flag: false, price: 2.5 this prints:

name=[] missing=[] nested=[]
not=[yes] and=[yes] or=[yes]
math=[15] mod=[3] price=[2.5] int=[7] bool=[false]
items=[[&quot;a&quot;, &quot;b&quot;, &quot;c&quot;]] count=[3] contains=[true]
with:[Dune/1965]
nestedloop:xfalseytrue
0aF|1b|2cL|

Escaping and XSS

#(variable) and the output of every tag are HTML-escaped by default: &, <, >, " and ' become entities. That is the protection against stored and reflected cross-site scripting, and it is why the <script> review in books/show.leaf came out inert. The escaping is applied to the result of the tag, not to your template text, so the HTML you write yourself is copied as is. Rules that follow:

  • The only ways to emit unescaped text are #unsafeHTML(value) and a custom tag that conforms to UnsafeUnescapedLeafTag. Use them only for markup your own code produced or sanitized (a Markdown-to-HTML result from a trusted editor, an SVG icon string). Never pass user input, a Markdown conversion of user input without a sanitizer, or a database field a user can set.

  • Escaping is context-free: it is correct for text between tags and for double-quoted attribute values. It is not enough inside a <script> block, in an inline event handler, in a style attribute, or in an unquoted attribute. Do not interpolate user data into JavaScript; put it in a data- attribute (quoted) and read it from the script, or serialize it to JSON in Swift and use a dedicated, escaped, type="application/json" element.

  • URLs need their own validation: href="#(url)" escapes quotes but still allows javascript:. Validate the scheme in Swift before putting a user-supplied URL in the context.

  • The #dumpContext demo above shows that the debug output is escaped as well, so it is safe to look at, but not safe to leave in.

  • A Content-Security-Policy header, set by a middleware (see the Middleware page and the Security Hardening page), is the second layer: it limits what an injected script can do even when an escaping rule is missed.

Configuration, the cache and template sources

app.leaf is a small struct over per-application storage with these members (all settable before the first render):

Member Meaning

app.leaf.configuration

A LeafConfiguration: the rootDirectory for templates (default app.directory.viewsDirectory) and ignoreUnfoundImports (default true). Its initializer can also take a different tag indicator, once per process.

app.leaf.cache

The LeafCache; the default is DefaultLeafCache, an in-memory cache of parsed templates keyed by name. app.leaf.cache.isEnabled = false turns it off.

app.leaf.sources

The LeafSources the renderer reads from; the default is one NIOLeafFiles source rooted at the configuration’s root directory. Add a source to read templates from somewhere else (a database, an embedded dictionary in tests).

app.leaf.tags

A [String: any LeafTag] dictionary pre-filled with the ten built-ins; custom tags are registered here.

app.leaf.userInfo

Values handed to every tag through ctx.userInfo.

import Leaf
import LeafKit
import Vapor

/// Optional Leaf settings; call before the first view is rendered.
func tuneLeaf(_ app: Application) {
    // Templates in "Templates/" instead of "Resources/Views/".
    app.leaf.configuration = LeafConfiguration(rootDirectory: app.directory.workingDirectory + "Templates/")

    // Compiled templates are cached unless the environment is .development, where Leaf already turns the
    // cache off. Say so explicitly if you like, but only for development: testing, staging and production
    // keep the cache on.
    if app.environment == .development {
        app.leaf.cache.isEnabled = false
    }
}

/// Renders a template to a String, for example for an e-mail body.
func renderToString(_ req: Request, _ name: String, _ context: some Encodable) async throws -> String {
    let view = try await req.view.render(name, context)
    return String(buffer: view.data)
}

The cache has one rule that explains the most common "my change does not show" report: when the application’s environment is .development, the renderer is created with the cache disabled (the 4.5.2 source of Application.Leaf.renderer does cache.isEnabled = false for .development); in any other environment, including .production and .testing, templates are parsed once and kept. Running the same binary both ways shows it: in production a template that failed to render kept failing after the file was fixed, until the process was restarted; in development the fix showed on the next request. Consequences:

  • Do not turn the cache off in production: every request would re-read and re-parse the files.

  • Deploy templates with the binary and restart on deploy; there is no hot reload in production.

  • A custom environment (for example staging) caches, which is what you want for it.

  • LeafConfiguration also has process-wide, write-once static settings (tagIndicator, encoding, and formatters such as boolFormatter and doubleFormatter) that must be set before the first LeafRenderer exists; changing them later is ignored (and trips an assert in debug builds). Changing the tag indicator is rarely worth it; if a template has to contain many literal #, escape them with \#.

Testing rendered pages

Rendering is deterministic, so a page is tested like any other route (see the Testing page), by asserting on the HTML. The two tests below ran with VaporTesting and Swift Testing against in-memory SQLite:

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite struct LeafTests {
    @Test func catalogueRendersEscapedTitles() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await app.testing().test(.GET, "books/demo?user=ana") { res async in
                #expect(res.status == .ok)
                #expect(res.headers.contentType == .html)
                let html = res.body.string
                #expect(html.contains("Tom &amp; Jerry &lt;Annual&gt;"))
                #expect(!html.contains("<Annual>"))
                #expect(html.contains("Signed in as ana"))
                #expect(html.contains("<td>PUBLISHED</td>"))
            }
        } catch { try await app.asyncShutdown(); throw error }
        try await app.asyncShutdown()
    }

    @Test func databaseBackedCatalogueListsOnlyPublished() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            let author = Author(name: "Frank Herbert")
            try await author.save(on: app.db)
            let authorID = try author.requireID()
            try await Book(title: "Dune", isbn: "9780441013593", publishedYear: 1965, priceCents: 999,
                           status: .published, authorID: authorID).save(on: app.db)
            try await Book(title: "Unreleased", isbn: "9780000000019", publishedYear: 2030, priceCents: 100,
                           status: .draft, authorID: authorID).save(on: app.db)
            try await app.testing().test(.GET, "books") { res async in
                let html = res.body.string
                #expect(html.contains("Dune"))
                #expect(!html.contains("Unreleased"))
                #expect(html.contains("<td>9.99</td>"))
            }
        } catch { try await app.asyncShutdown(); throw error }
        try await app.asyncShutdown()
    }
}

Output of swift test:

✔ Test catalogueRendersEscapedTitles() passed after 0.031 seconds.
✔ Test databaseBackedCatalogueListsOnlyPublished() passed after 0.031 seconds.
✔ Suite LeafTests passed after 0.031 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.031 seconds.

Assert on a few stable fragments (the escaped title, a status cell, the absence of a draft), not on the whole document, because whitespace around tags changes whenever someone reformats a template. The .testing environment is not .development, so templates are cached there too. For end-to-end checks of layout and behaviour in a real browser, see the site’s page on end-to-end testing in the browser.

Errors you will meet

Messages below were produced by the scratch package (the JSON the default error middleware sends is {"error":true,"reason":"..."}):

Message Cause Fix

No template found for probe/missing

The name does not match a file below the views directory, or the working directory is wrong (Xcode).

Check the path and the extension-less name; set the working directory.

#isEmpty(): unable to check for empty value; cannot convert parameter to string

isEmpty applied to an array.

#if(count(books) == 0):.

expected array at key: n

#for over a value that is not an array.

Pass an array or wrap the value.

LexerError(... reason: LeafKit.LexerError.Reason.invalidParameterToken("?") ...)

An unsupported token in a parameter list: ??, a subscript [, a stray newline after an unclosed (.

Rewrite the expression; compute in Swift.

= is not yet supported as an operator

Assignment such as #let(x = 5).

There are no local variables; compute in Swift.

#date(): Unable to convert parameter to double for date

date given something that is not a number (a String already formatted, an optional that is nil).

Pass the Date itself.

... cyclically referenced in [...]

Templates extend each other in a loop.

Break the cycle.

Two quieter failures produce no error: an unclosed body (a #if(n):x without #endif rendered an empty page in the probe) and a typo in a variable name (it prints nothing). Both are caught by a test that asserts on a fragment of the output. A LeafError always travels to the client with status 500 and its reason text, which for lexer errors includes the template name and the tokens lexed so far. Put an error-rendering middleware in front for browsers (the Error Handling page) so that visitors see an HTML error page, and the detail goes to the log only.

Leaf 2/3 syntax to Leaf 4

The books listed in the section’s bibliography use Leaf 2 (Patel, on Vapor 2) and Leaf 3 (Yeung, and chapters 14 to 36 of Condon et al., on Vapor 3); their templates do not render on Leaf 4 as written. The Leaf 3 column follows the archived Leaf 3 documentation (docs.vapor.codes/3.0/leaf). Leaf 2 shares most tags with Leaf 3 and additionally had a #loop tag; both are replaced by the Leaf 4 forms on the right.

Concern Leaf 2/3 Leaf 4 (this page)

Register

try services.register(LeafProvider()) and config.prefer(LeafRenderer.self, for: ViewRenderer.self) (Leaf 3)

app.views.use(.leaf)

Render

try req.view().render("hello", ctx), a Future<View>

try await req.view.render("hello", ctx)

Condition

#if(title) { ... } else if (x) { ... } else { ... }

#if(title): ... #elseif(x): ... #else: ... #endif

Loop

#for(planet in planets) { ... } (Leaf 2 also had #loop)

#for(planet in planets): ... #endfor; indexed form #for(i, planet in planets):

Loop metadata

isFirst, isLast, index

Same; the index variable can be named

Layout and partials

#set("body") { ... }, #get(body) in the master, #embed("footer")

#extend("base"):, #export("body"): / #endexport, #import("body"), and #extend("footer") as an include

Comments

#// line and #/* block */

#comment("text")

Case tags

#lowercase, #uppercase, #capitalize

#lowercased, #uppercased, #capitalized

#date

format pattern as second parameter

Same, plus an optional time zone as third parameter

Body delimiters

Braces { }, which clash with CSS and JavaScript braces in the same template

Colon and #end... tags; braces are plain text

Package

vapor/leaf 2.x/3.x on Vapor 2/3

vapor/leaf 4.x with LeafKit 1.x

Swift 6 and concurrency notes

  • Everything in the render path is async; no .wait() and no EventLoopFuture appear in new code. The futures overload (render(_:_:) -> EventLoopFuture<View>) is still the requirement of the 4.x ViewRenderer protocol and is what the async method calls.

  • app.leaf.tags and app.leaf.userInfo are plain dictionaries in storage that is written during configuration; mutate them only in configure, never from a handler, or you introduce a data race that the compiler cannot see (the storage class is @unchecked Sendable).

  • LeafKit’s LeafConfiguration static settings and defaultTags are nonisolated(unsafe) globals in LeafKit 1.14.3; treat them as write-once, set before the server starts.

  • Custom tags receive a LeafContext whose userInfo holds non-Sendable references to the Request and Application; read them synchronously inside render, do not store them.

  • A view-model is a plain Sendable struct: it can be built in one task (a Fluent query) and rendered in another with no warnings under complete strict concurrency.

Version notes

  • Leaf 2 and 3 (Vapor 2 and 3) are unmaintained; their brace syntax and #set/#get/#embed are not accepted by Leaf 4.

  • Leaf 4 (2020 onward, Vapor 4) is built on LeafKit 1.x: colon bodies, #extend/#export/#import, ViewRenderer integration. The documentation still says from: "4.4.0"; 4.5.2 is the release used here.

  • Cache in development. The disabling of the cache for .development is in the 4.5.2 source; the documentation says only that the cache is off during development.

  • Vapor 5. There is no Leaf release that targets Vapor 5 at the time of writing (vapor/leaf main still depends on Vapor 4.x). In the 5.0.0-beta.4 sources the ViewRenderer protocol has only an async throws render(_:_:) requirement (in 4.122 the requirement returns an EventLoopFuture<View> and the async method is an extension), so a Leaf for Vapor 5 has to be a new release. Keep templates free of Swift specifics and they will carry over; the app.views.use(.leaf) call and req.view.render are the surface that may be renamed.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco) renders the TIL web site with Leaf 3 in chapters 14 and 15: contexts as Encodable structs, #if/#for/#count, a base.leaf layout with #embed/#set/#get, and Bootstrap. The concepts (context structs, escaping, a layout and partials) are unchanged; the braces, #set/#get/#embed, req.view().render and the LeafProvider registration are replaced as in the table above.

  • Yeung (Packt) uses Leaf 3 for partials and a Bootstrap grid, navbar and forms loaded from a CDN with Subresource Integrity. The partial idea maps to #extend("partial"); the Bootstrap part is covered on the Server-Rendered Web Apps and Forms page.

  • Patel (Packt) uses Leaf 2 layouts and one set of routes that answers HTML and JSON (content negotiation); Leaf 2 syntax does not apply to Leaf 4, and Vapor 2’s drop.view is req.view.