Server-Rendered Web Apps and Forms

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), fluent 4.13.0, fluent-sqlite-driver 4.9.0, Bootstrap 5.3.8 and htmx 2.0.11, 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 Leaf template was rendered by that package, and the behaviour described was verified with a VaporTesting suite (eight tests, all passing) and with curl against the running service on SQLite; the output shown is captured from those runs. The JavaScript halves of the Bootstrap and htmx features were not exercised in a browser here: what the page claims about them rests on the server-side requests and responses and on the official htmx and Bootstrap documentation, and it says so where it matters. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the code targets 4.122.x, and the Leaf integration for Vapor 5 is listed among the things to check before moving in the version notes.

The page builds the Bookshelf catalogue UI on the canonical models of the earlier pages of this section (Book, Author, Review, Tag), with no schema changes. It uses the view and routing machinery explained elsewhere: the Leaf Templates page covers the template language in depth, and this page uses only what a CRUD application needs. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable.

What "server-rendered" means here

In a server-rendered application the server answers a browser’s request with finished HTML. The browser never needs to run JavaScript to show a page or to submit a form: links are GET requests, forms are POST requests, and every state change is a full round trip that ends in another page. That model has properties worth wanting:

  • it works with the browser’s own features (back button, bookmarks, form autofill, password managers, screen readers) because the pages are plain documents;

  • all validation and authorisation runs on the server, where it has to run anyway, and the template is the only place that formats output;

  • there is one codebase and one deployment: no API versioning between a front end and a back end, no client-side state to keep in sync.

The costs are a round trip per interaction and the discipline of keeping state out of the page. Progressive enhancement is the bridge: the application is complete and correct without JavaScript, and a small library (htmx in this page) upgrades some interactions to partial-page updates, falling back to the full-page behaviour when it does not run. When to choose this model rather than a JSON API plus a separate front end (or an HTML DSL instead of Leaf) is discussed at the end of the Leaf Templates and Leaf Custom Tags and HTML DSLs pages; this page assumes the decision is made and shows the whole application.

The application at a glance

The routes below are everything the browser UI needs. A plain HTML form can only send GET and POST, so updating and deleting use POST to a verb-carrying URL; the single DELETE route exists for htmx, which can send real DELETE requests.

Method and path Handler Result

GET /books?q=&page=

index

HTML list with search and pagination; only the table fragment when htmx asked; a JSON array when the client prefers JSON

GET /books/new

newForm

empty form

POST /books

create

303 to the new book’s page, or 422 with the form re-rendered

GET /books/:bookID

show

HTML detail page, or the book as JSON on request

GET /books/:bookID/edit

editForm

form filled with the current values

POST /books/:bookID

update

303 back to the book’s page, or 422

POST /books/:bookID/delete

destroy

303 to the list with a flash message

DELETE /books/:bookID

destroyHTMX

empty 200, so htmx can remove the table row

Every route lives in a RouteCollection (Controllers and Route Collections) behind the sessions middleware and a CSRF middleware, both described below.

Where it sits in the request

In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor builds a Request, the responder chain runs the middleware, the router matches a route, the handler runs, and its return value is encoded into the Response), a server-rendered page touches more of the chain than a JSON endpoint does, because it needs a session and an HTML-aware error path. This is the order configured in configure.swift, outermost first:

  1. ErrorMiddleware (installed by default): turns anything still thrown into a JSON error response.

  2. SessionsMiddleware (app.sessions.middleware): reads the session cookie on the way in, loads the Session from the driver, and on the way out saves it and sets the cookie. Everything inside it can use req.session.

  3. FileMiddleware: serves files from Public/ and falls through to the next link for anything else.

  4. HTMLErrorMiddleware (page-local): catches errors from the handlers and renders an HTML error page for browsers.

  5. CSRFMiddleware (a route-group middleware, only for /books): rejects POST, PUT, PATCH and DELETE without a valid token.

  6. The router, then the handler, which calls the database, builds a view model and renders a Leaf template into a View, and the View is encoded into a Response.

Two ordering rules follow. The CSRF middleware must be inside the sessions middleware, because it reads and writes the session. The HTML error middleware must be inside ErrorMiddleware (so it sees the thrown error before the default converts it) and, here, inside the sessions middleware as well (so its error page can embed the session’s CSRF token and flash messages). The Middleware page explains the chain; the Routing page explains why group middleware does not run for an unknown URL (the router finds no route, so no group is entered, which is why a 404 for an unmatched path is handled by the global HTMLErrorMiddleware, not by a group).

sequenceDiagram participant B as Browser participant S as SessionsMiddleware participant C as CSRFMiddleware participant H as Handler participant D as Database B->>S: GET /books/new S->>C: new session C->>C: create token in session C->>H: pass H-->>B: 200 form with hidden _csrf field B->>S: POST /books with _csrf and cookie S->>C: session loaded C->>C: compare token with session C->>H: pass H->>H: validate fields alt invalid H-->>B: 422 form re-rendered with errors else valid H->>D: save book H->>S: flash "Created" in session H-->>B: 303 See Other to /books/id B->>S: GET /books/id S->>H: session loaded H-->>B: 200 page, flash shown once and cleared end

Project setup

The scratch package is the pinned Bookshelf package of the earlier pages, with Leaf added. These are the two changes to Package.swift:

.package(url: "https://github.com/vapor/leaf.git", exact: "4.5.2"),
// ...
.product(name: "Leaf", package: "leaf"),

The templates live in Resources/Views/, which is the directory Leaf reads by default (app.directory.viewsDirectory, relative to the working directory the process runs in). configure.swift wires Leaf, the sessions, the middleware order above and the routes:

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()
    if app.environment != .testing || Environment.get("SEED") == "1" { try await seed(on: app.db) }

    // Templates: Resources/Views, cached only in production so edits show up on reload.
    app.views.use(.leaf)
    app.leaf.cache.isEnabled = app.environment.isRelease

    // Sessions: in-memory (one instance only) with a hardened cookie.
    let secure = app.environment.isRelease
    app.sessions.use(.memory)
    app.sessions.configuration = .init(cookieName: "bookshelf-session") { id in
        HTTPCookies.Value(string: id.string, expires: Date(timeIntervalSinceNow: 8 * 3600), maxAge: nil, domain: nil,
                          path: "/", isSecure: secure, isHTTPOnly: true, sameSite: .lax)
    }

    // Order (outside in): default ErrorMiddleware, sessions, static files, HTML error pages.
    app.middleware.use(app.sessions.middleware)
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    app.middleware.use(HTMLErrorMiddleware())

    app.get { req in req.redirect(to: "/books", redirectType: .normal) }
    // The site's own origin, for the CSRF middleware's Origin check. Set PUBLIC_ORIGIN in production.
    let origin = Environment.get("PUBLIC_ORIGIN") ?? "http://127.0.0.1:8080"
    try app.register(collection: WebBooksController(allowedOrigins: [origin]))
}

Notes on the choices:

  • app.views.use(.leaf) makes Leaf the renderer behind req.view. app.leaf.cache.isEnabled is set to app.environment.isRelease: with the cache on, a template is parsed once and edits to the .leaf files are not seen until restart, which is right in production and wrong while developing.

  • app.sessions.use(.memory) keeps sessions in the process’s memory. That is correct for a demo and for tests, and wrong for anything with two instances or restarts: the Fluent and Redis drivers of Sessions and Cookies replace it without touching the code on this page, because everything goes through req.session.

  • The session cookie is configured explicitly: HttpOnly (scripts cannot read it), SameSite=Lax (the browser does not send it on cross-site POST requests), Secure in release builds (the browser sends it only over HTTPS, so a release build tried over plain http://localhost never keeps its session), and an eight-hour expiry.

  • The seed runs on start-up for the demo and is skipped in the testing environment, so the tests create their own rows.

The base layout and the Leaf tags used

Leaf 4 layouts use #extend, #export and #import: a page extends the base template and exports named blocks, and the base template imports them where they belong. The layout carries everything shared by all pages: the Bootstrap stylesheet and scripts with their integrity hashes, a skip link, the navigation, the flash messages and the htmx configuration.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>#(title) - Bookshelf</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
        integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
  <meta name="htmx-config" content='{"historyRestoreAsHxRequest": false, "responseHandling": [{"code": "204", "swap": false}, {"code": "[23]..", "swap": true}, {"code": "422", "swap": true}, {"code": "[45]..", "swap": false, "error": true}]}'>
</head>
<body hx-headers='{"X-CSRF-Token": "#(csrf)"}'>
  <a class="visually-hidden-focusable" href="#main">Skip to main content</a>
  <nav class="navbar navbar-expand bg-body-tertiary" aria-label="Main">
    <div class="container">
      <a class="navbar-brand" href="/books">Bookshelf</a>
      <ul class="navbar-nav">
        <li class="nav-item"><a class="nav-link" href="/books">Books</a></li>
        <li class="nav-item"><a class="nav-link" href="/books/new">New book</a></li>
      </ul>
    </div>
  </nav>
  <main id="main" class="container my-4">
    #for(flash in flashes):
    <div class="alert alert-#(flash.kind)" role="#(flash.role)">#(flash.text)</div>
    #endfor
    #import("content")
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
          integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
          integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg" crossorigin="anonymous"></script>
</body>
</html>

Points to notice in the layout:

  • #(title), #(csrf) and #(flashes) come from the top level of the context object, so every page must provide them. The Page wrapper below guarantees that.

  • The page-specific data sits under content, so a template reads #(content.book.title). Keeping the layout’s fields at the top and the page’s data in one named property stops a page from accidentally shadowing title or csrf.

  • #(value) HTML-escapes the value. A title of <script>x</script> is printed as text (<script>x</script> in the HTML source), which is the protection against stored cross-site scripting. Output that must not be escaped goes through #unsafeHTML(...); this page never needs it, and every use of it is a place to review.

  • A # followed by something that is not a tag, as in href="#main", is left in the output untouched: the lexer in LeafKit 1.14.3 recognises it as a tag name candidate and falls back to plain text, and the rendered page contains href="#main" as written.

  • The tags used on this page are #extend, #export, #import, #for, #if/#else/#endif and #count. Leaf 4.5.2 has no ternary operator: a template that contains #(a == b ? "x" : "y") fails with a LexerError ("invalidParameterToken") on the ?. It also has no #empty branch in a #for loop. Both limits are handled in Swift: the view models carry precomputed values (FlashView.role, BookRow.badge) and the templates use #if(count(list) == 0).

The page wrapper, flash messages and the CSRF token

Everything a page needs besides its own data goes through one generic wrapper and a few Request extensions. The flash message is a value stored in the session and removed on the first read; the CSRF token, req.csrfToken, is created on first use and kept in the session (it is defined next to the middleware, in CSRF protection).

import Vapor

/// A one-shot message shown on the next page (Bootstrap alert colour in `kind`).
struct FlashMessage: Codable, Sendable {
    let kind: String
    let text: String
}

extension Request {
    /// Queues a flash message in the session; it is shown on the next rendered page.
    func flash(_ kind: String, _ text: String) {
        var all = peekFlashes()
        all.append(FlashMessage(kind: kind, text: text))
        if let data = try? JSONEncoder().encode(all) {
            session.data["flash"] = String(decoding: data, as: UTF8.self)
        }
    }

    private func peekFlashes() -> [FlashMessage] {
        guard let json = session.data["flash"] else { return [] }
        return (try? JSONDecoder().decode([FlashMessage].self, from: Data(json.utf8))) ?? []
    }

    /// Returns the queued messages and removes them, so each is displayed exactly once.
    func takeFlashes() -> [FlashMessage] {
        let all = peekFlashes()
        session.data["flash"] = nil
        return all
    }
}

/// A flash message as the template sees it: Leaf has no ternary operator, so the ARIA role is computed here.
struct FlashView: Encodable, Sendable {
    let kind: String
    let text: String
    let role: String
    init(_ message: FlashMessage) {
        kind = message.kind
        text = message.text
        role = message.kind == "danger" ? "alert" : "status"
    }
}

/// Everything the base layout needs, plus the page-specific `content`.
struct Page<Content: Encodable & Sendable>: Encodable, Sendable {
    let title: String
    let csrf: String
    let flashes: [FlashView]
    let content: Content

    init(_ title: String, _ req: Request, content: Content) {
        self.title = title
        self.csrf = req.csrfToken
        self.flashes = req.takeFlashes().map(FlashView.init)
        self.content = content
    }
}

extension Request {
    /// True when htmx issued the request (and it is not a history-cache restore).
    var isHTMX: Bool {
        headers.first(name: "HX-Request") == "true" && headers.first(name: "HX-History-Restore-Request") != "true"
    }

    /// JSON only when the client explicitly prefers it; a missing or wildcard `Accept` means HTML.
    var wantsJSON: Bool {
        headers.accept.comparePreference(for: .json, to: .html) == .orderedDescending
    }
}

extension Response {
    func varying(_ names: String...) -> Response {
        headers.replaceOrAdd(name: .vary, value: names.joined(separator: ", "))
        return self
    }
}

How this works:

  • Page<Content> is generic over any Encodable & Sendable payload. Leaf’s renderer accepts any Encodable context (it encodes it with its own encoder), so no dictionary of LeafData is needed. Because the initializer takes the Request, the token and the pending flash messages are always read at render time, for every page, in one place.

  • flash(_:_:) appends to a JSON array held in req.session.data["flash"]; takeFlashes() reads and clears it. Session data is a [String: String], so structured values are stored as JSON text. After a 303 redirect the next request loads the session, Page.init takes the messages, and the session is saved without them, so a reload shows no message.

  • Two flash messages queued before one render are both shown. SessionsMiddleware saves the session only when the inner chain returns a Response; an error that is thrown all the way through it skips the save. Here HTMLErrorMiddleware turns errors into responses inside the sessions middleware for browsers, so their sessions are saved, but a JSON client’s thrown error is not, so a change made to the session before the failure is lost. Flash only on the success path, as the controller does.

  • Request.wantsJSON and Request.isHTMX are the two decisions the controller makes about the representation, both based on request headers. They are covered in the content negotiation and htmx sections below.

  • varying(_:) sets Vary on a response whose body depends on request headers, so a shared cache does not hand the JSON version to a browser.

The list page: search and pagination

The index handler combines a Fluent query with paginate, a search term and the three representations. A BookRow is the view model for a row: the API fields (BookDTO) plus the presentation detail Leaf cannot compute.

import Vapor
import Fluent

func formatPrice(_ cents: Int) -> String {
    let fraction = cents % 100
    return "\(cents / 100).\(fraction < 10 ? "0" : "")\(fraction)"
}

/// What the list and detail templates need, and the JSON body for API clients.
struct BookDTO: Content {
    let id: UUID
    let title: String
    let isbn: String
    let author: String
    let publishedYear: Int
    let price: String
    let status: String

    init(_ book: Book) throws {
        self.id = try book.requireID()
        self.title = book.title
        self.isbn = book.isbn
        self.author = book.$author.value?.name ?? ""
        self.publishedYear = book.publishedYear
        self.price = formatPrice(book.priceCents)
        self.status = book.status.rawValue
    }
}

/// A table row: the same fields as the API body plus presentation-only data (Leaf cannot compute a badge colour).
struct BookRow: Encodable, Sendable {
    let book: BookDTO
    let badge: String

    init(_ book: Book) throws {
        self.book = try BookDTO(book)
        self.badge = book.status == .published ? "success" : "secondary"
    }
}

struct BookListView: Encodable, Sendable {
    let rows: [BookRow]
    let q: String
    /// `q` percent-encoded for use inside an `href` (Leaf escapes HTML, not URLs).
    let encodedQ: String
    let page: Int
    let pageCount: Int
    let total: Int
    let hasPrevious: Bool
    let hasNext: Bool
    let previousPage: Int
    let nextPage: Int
}

struct ReviewView: Encodable, Sendable {
    let rating: Int
    let body: String
}

struct BookDetailView: Encodable, Sendable {
    let book: BookDTO
    let tags: [String]
    let reviews: [ReviewView]
}

struct Option: Encodable, Sendable {
    let value: String
    let label: String
    let selected: Bool
}

struct BookFormView: Encodable, Sendable {
    let action: String
    let heading: String
    let submitLabel: String
    let values: BookForm
    let errors: FieldErrors
    let errorList: [String]
    let hasErrors: Bool
    let authors: [Option]
    let statuses: [Option]

    static func make(action: String, heading: String, submitLabel: String, values: BookForm,
                     errors: FieldErrors, db: any Database) async throws -> BookFormView {
        let authors = try await Author.query(on: db).sort(\.$name).all().map {
            Option(value: $0.id?.uuidString ?? "", label: $0.name, selected: $0.id?.uuidString == values.authorId)
        }
        let statuses = [BookStatus.draft, .published, .archived].map {
            Option(value: $0.rawValue, label: $0.rawValue.capitalized, selected: $0.rawValue == (values.status ?? "draft"))
        }
        return BookFormView(action: action, heading: heading, submitLabel: submitLabel, values: values,
                            errors: errors, errorList: ["title", "isbn", "publishedYear", "price", "authorId", "status", "tags"].compactMap { errors[$0] },
                            hasErrors: !errors.isEmpty, authors: authors, statuses: statuses)
    }
}

struct ErrorView: Encodable, Sendable {
    let status: Int
    let reason: String
}

Models never reach a template. A Fluent model is @unchecked Sendable in this package, references a database, and encodes its relations only when they are loaded; copying the fields a page needs into a plain Sendable struct keeps the template independent of the schema (a renamed column does not break a template), keeps passwords and internal fields out of the page, and keeps the Swift 6 concurrency checker happy. The same struct (BookDTO) doubles as the JSON body for API clients.

The handler for the whole controller follows. It is shown in full because the other handlers use the same shape: parse input, run the query, build a view model, choose a representation.

import Vapor
import Fluent

struct WebBooksController: RouteCollection {
    let allowedOrigins: Set<String>

    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books").grouped(CSRFMiddleware(allowedOrigins: allowedOrigins))
        books.get(use: index)
        books.post(use: create)
        books.get("new", use: newForm)
        books.group(":bookID") { book in
            book.get(use: show)
            book.post(use: update)
            book.delete(use: destroyHTMX)
            book.get("edit", use: editForm)
            book.post("delete", use: destroy)
        }
    }

    // GET /books?q=&page=
    @Sendable func index(req: Request) async throws -> Response {
        let q = (try? req.query.get(String.self, at: "q"))?.trimmingCharacters(in: .whitespaces) ?? ""
        let pageNumber = max(1, (try? req.query.get(Int.self, at: "page")) ?? 1)
        var query = Book.query(on: req.db).with(\.$author).sort(\.$title)
        if !q.isEmpty { query = query.filter(\.$title ~~ q) }
        let page = try await query.paginate(PageRequest(page: pageNumber, per: 5))
        let pageCount = max(1, page.metadata.pageCount)
        let list = BookListView(
            rows: try page.items.map(BookRow.init), q: q,
            encodedQ: q.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "", page: pageNumber, pageCount: pageCount,
            total: page.metadata.total, hasPrevious: pageNumber > 1, hasNext: pageNumber < pageCount,
            previousPage: pageNumber - 1, nextPage: pageNumber + 1)

        if req.wantsJSON { return try await list.rows.map(\.book).encodeResponse(for: req).varying("Accept", "HX-Request") }
        let template = req.isHTMX ? "books/_table" : "books/index"
        let view = try await req.view.render(template, Page("Books", req, content: list))
        return try await view.encodeResponse(for: req).varying("Accept", "HX-Request")
    }

    // GET /books/:bookID : one URL, two representations
    @Sendable func show(req: Request) async throws -> Response {
        let book = try await loadBook(req, with: true)
        let dto = try BookDTO(book)
        if req.wantsJSON { return try await dto.encodeResponse(for: req).varying("Accept") }
        let detail = BookDetailView(
            book: dto, tags: try await book.$tags.get(on: req.db).map(\.name),
            reviews: book.reviews.map { ReviewView(rating: $0.rating, body: $0.body) })
        let view = try await req.view.render("books/show", Page(book.title, req, content: detail))
        return try await view.encodeResponse(for: req).varying("Accept")
    }

    // GET /books/new
    @Sendable func newForm(req: Request) async throws -> View {
        let form = try await BookFormView.make(action: "/books", heading: "New book", submitLabel: "Create book",
                                               values: BookForm(status: "draft"), errors: [:], db: req.db)
        return try await req.view.render("books/form", Page("New book", req, content: form))
    }

    // POST /books : validate, then redirect (POST-redirect-GET) or re-render with 422
    @Sendable func create(req: Request) async throws -> Response {
        let form = try req.content.decode(BookForm.self)
        switch try await form.validated(on: req.db) {
        case .valid(let input):
            let book = Book(title: input.title, isbn: input.isbn, publishedYear: input.publishedYear,
                            priceCents: input.priceCents, status: input.status, authorID: input.authorID)
            try await req.db.transaction { db in
                try await book.save(on: db)
                try await book.syncTags(input.tags, on: db)
            }
            req.flash("success", "Created \u{201C}\(book.title)\u{201D}.")
            return req.redirect(to: "/books/\(try book.requireID())", redirectType: .normal)
        case .invalid(let errors):
            let view = try await BookFormView.make(action: "/books", heading: "New book", submitLabel: "Create book",
                                                   values: form, errors: errors, db: req.db)
            return try await invalid(req, "New book", view)
        }
    }

    // GET /books/:bookID/edit
    @Sendable func editForm(req: Request) async throws -> View {
        let book = try await loadBook(req)
        let values = BookForm(title: book.title, isbn: book.isbn, publishedYear: String(book.publishedYear),
                              price: formatPrice(book.priceCents), status: book.status.rawValue,
                              authorId: book.$author.id.uuidString,
                              tags: try await book.$tags.get(on: req.db).map(\.name).sorted().joined(separator: ", "))
        let form = try await BookFormView.make(action: "/books/\(try book.requireID())", heading: "Edit book",
                                               submitLabel: "Save changes", values: values, errors: [:], db: req.db)
        return try await req.view.render("books/form", Page("Edit \(book.title)", req, content: form))
    }

    // POST /books/:bookID
    @Sendable func update(req: Request) async throws -> Response {
        let book = try await loadBook(req)
        let id = try book.requireID()
        let form = try req.content.decode(BookForm.self)
        switch try await form.validated(on: req.db, excluding: id) {
        case .valid(let input):
            book.title = input.title
            book.isbn = input.isbn
            book.publishedYear = input.publishedYear
            book.priceCents = input.priceCents
            book.status = input.status
            book.$author.id = input.authorID
            try await req.db.transaction { db in
                try await book.update(on: db)
                try await book.syncTags(input.tags, on: db)
            }
            req.flash("success", "Saved \u{201C}\(book.title)\u{201D}.")
            return req.redirect(to: "/books/\(id)", redirectType: .normal)
        case .invalid(let errors):
            let view = try await BookFormView.make(action: "/books/\(id)", heading: "Edit book", submitLabel: "Save changes",
                                                   values: form, errors: errors, db: req.db)
            return try await invalid(req, "Edit book", view)
        }
    }

    // POST /books/:bookID/delete : plain HTML forms can only send GET and POST
    @Sendable func destroy(req: Request) async throws -> Response {
        let book = try await loadBook(req)
        try await book.delete(on: req.db)
        req.flash("success", "Deleted \u{201C}\(book.title)\u{201D}.")
        return req.redirect(to: "/books", redirectType: .normal)
    }

    // DELETE /books/:bookID : the htmx variant; an empty 200 lets htmx swap the row away
    @Sendable func destroyHTMX(req: Request) async throws -> Response {
        try await loadBook(req).delete(on: req.db)
        return Response(status: .ok)
    }

    private func loadBook(_ req: Request, with details: Bool = false) async throws -> Book {
        // A malformed id is a 404, not a 400: the URL simply names nothing.
        guard let id = req.parameters.get("bookID", as: UUID.self) else { throw Abort(.notFound, reason: "No such book.") }
        var query = Book.query(on: req.db).filter(\.$id == id).with(\.$author)
        if details { query = query.with(\.$reviews) }
        guard let book = try await query.first() else { throw Abort(.notFound, reason: "No such book.") }
        return book
    }

    /// A rejected form is re-rendered with status 422 so that clients (and tests) can tell it was not saved.
    private func invalid(_ req: Request, _ title: String, _ form: BookFormView) async throws -> Response {
        let view = try await req.view.render("books/form", Page(title, req, content: form))
        let response = try await view.encodeResponse(for: req)
        response.status = .unprocessableEntity
        return response
    }
}

These routes have no login and no role check. Anyone who can reach the server can create, edit and delete books (POST /books, POST /books/:bookID, POST /books/:bookID/delete and DELETE /books/:bookID); the CSRF middleware only stops other sites from making a visitor’s browser do it. The omission keeps the page about forms, and it is the same erratum as the books' unauthenticated DELETE, so do not deploy the controller as it is. In a real application the reading routes stay public and the changing ones go into a group behind the session authenticator and a redirect to the login page from Session Authentication and Web Login, plus a librarian role check from Authorization: Roles, Ownership and Policies.

For the list:

  • req.query.get(String.self, at: "q") reads the search term. An invalid page number falls back to 1 and max(1, ...) rejects zero and negatives.

  • The filter \.$title ~~ q is Fluent’s "contains" operator, which becomes a LIKE '%...%' match. Fluent binds q as a parameter, so a quote in the search term is not a SQL injection. % and _ in the term are not escaped, so they act as wildcards; this is acceptable for a search box and wrong for anything that treats the term as an exact string. On SQLite LIKE is case-insensitive for ASCII, on PostgreSQL it is case-sensitive (for case-insensitive search there, use an ILIKE through SQLKit, as the SQLKit and Raw SQL shows). Fluent Queries and Pagination covers the operators and paging strategies; page-number paging, used here, gets slow on deep pages, and keyset paging is the alternative.

  • paginate(PageRequest(page:per:)) issues a count query and a limited query. page.metadata supplies total and pageCount.

  • The links in the pager carry q percent-encoded (encodedQ), because Leaf escapes HTML but does not URL-encode. Putting a raw search term into an href would let &page=9 typed in the search box rewrite the link.

The list is split into a page and a fragment, so htmx can request just the table:

#extend("base"):
  #export("content"):
    <h1>Books</h1>
    <form class="row g-2 mb-3" method="get" action="/books" role="search">
      <div class="col-auto">
        <label class="visually-hidden" for="q">Search by title</label>
        <input id="q" name="q" type="search" class="form-control" placeholder="Search by title" value="#(content.q)"
               hx-get="/books" hx-trigger="input changed delay:300ms, search" hx-target="#book-table"
               hx-push-url="true" hx-sync="this:replace" autocomplete="off">
      </div>
      <div class="col-auto"><button class="btn btn-outline-secondary" type="submit">Search</button></div>
    </form>
    <div id="book-table" aria-live="polite">
      #extend("books/_table")
    </div>
  #endexport
#endextend
#if(content.total == 0):
<p>No books match.</p>
#else:
<table class="table table-striped align-middle">
  <caption>#(content.total) books, page #(content.page) of #(content.pageCount)</caption>
  <thead><tr><th scope="col">Title</th><th scope="col">Author</th><th scope="col">Year</th><th scope="col" class="text-end">Price</th><th scope="col">Status</th><th scope="col"><span class="visually-hidden">Actions</span></th></tr></thead>
  <tbody>
  #for(row in content.rows):
    <tr>
      <th scope="row"><a href="/books/#(row.book.id)">#(row.book.title)</a></th>
      <td>#(row.book.author)</td>
      <td>#(row.book.publishedYear)</td>
      <td class="text-end">#(row.book.price)</td>
      <td><span class="badge text-bg-#(row.badge)">#(row.book.status)</span></td>
      <td class="text-end">
        <a class="btn btn-sm btn-outline-primary" href="/books/#(row.book.id)/edit">Edit<span class="visually-hidden"> #(row.book.title)</span></a>
        <button class="btn btn-sm btn-outline-danger" type="button" hx-delete="/books/#(row.book.id)"
                hx-confirm="Delete this book?" hx-target="closest tr" hx-swap="outerHTML">Delete<span class="visually-hidden"> #(row.book.title)</span></button>
      </td>
    </tr>
  #endfor
  </tbody>
</table>
<nav aria-label="Pagination">
  <ul class="pagination">
    <li class="page-item#if(!content.hasPrevious): disabled#endif"><a class="page-link" href="/books?page=#(content.previousPage)&amp;q=#(content.encodedQ)"#if(!content.hasPrevious): aria-disabled="true" tabindex="-1"#endif>Previous</a></li>
    <li class="page-item#if(!content.hasNext): disabled#endif"><a class="page-link" href="/books?page=#(content.nextPage)&amp;q=#(content.encodedQ)"#if(!content.hasNext): aria-disabled="true" tabindex="-1"#endif>Next</a></li>
  </ul>
</nav>
#endif

#extend("books/_table") inside the page with no body includes the fragment as it is, so the full page and the htmx response render the same markup. A small design point: the fragment reads content too, so the page and fragment share one context type, and the controller chooses only the template name (books/index or books/_table).

The detail page and content negotiation

GET /books/:bookID serves two representations from one URL. Browsers send an Accept header that lists text/html first, API clients send application/json, and the handler returns whichever the client prefers:

// From WebSupport.swift
var wantsJSON: Bool {
    headers.accept.comparePreference(for: .json, to: .html) == .orderedDescending
}

headers.accept parses the header into media types with their quality values (q); comparePreference(for:to:) compares two of them. The rule here is deliberately conservative: JSON is returned only when the client lists it and either does not list HTML or gives it a lower quality. A missing header, */*, or a browser’s text/html,...,*/*;q=0.8 all produce HTML. The Vary: Accept header the handler adds is not decoration: without it a CDN or the browser’s own HTTP cache may store the JSON body for the URL and serve it to the next browser request. The captured behaviour:

Accept: text/html,application/xhtml+xml,*/*;q=0.8   ->  Content-Type: text/html; charset=utf-8
Accept: application/json                             ->  Content-Type: application/json; charset=utf-8, Vary: Accept
Accept: */*                                          ->  Content-Type: text/html; charset=utf-8
(no Accept header)                                   ->  Content-Type: text/html; charset=utf-8

Content negotiation on one URL is convenient for small applications and for a JSON view of exactly what the page shows. It has limits. The two representations must stay in step, a URL that returns two content types is harder to cache and to document, and an API for mobile apps usually wants its own versioned URLs and error format; API Design, Versioning and DTOs describes that route. For a real public API, prefer a separate /api/v1 route group and keep the HTML routes for the browser. The Accept header is also not a security boundary: authorisation applies to both representations alike.

Forms: decoding, validating and re-rendering

The create and edit pages share one template and one pair of types. The form type holds the raw text exactly as the browser sent it, and a validation function turns it into typed input or a message per field.

import Vapor
import Fluent

/// The raw form fields, exactly as the browser sent them. Everything is an optional String so a
/// missing or malformed field never fails decoding; validation reports it per field instead.
struct BookForm: Content {
    var title: String?
    var isbn: String?
    var publishedYear: String?
    var price: String?
    var status: String?
    var authorId: String?
    var tags: String?          // comma-separated tag names
}

/// The validated, typed result.
struct BookInput: Sendable {
    let title: String
    let isbn: String
    let publishedYear: Int
    let priceCents: Int
    let status: BookStatus
    let authorID: UUID
    let tags: [String]
}

typealias FieldErrors = [String: String]

/// The outcome of validating a form: typed input, or one message per invalid field.
enum FormOutcome: Sendable {
    case valid(BookInput)
    case invalid(FieldErrors)
}

extension BookForm {
    /// Validates the fields; `excluding` is the book being edited, so its own ISBN is not a duplicate.
    func validated(on db: any Database, excluding bookID: UUID? = nil) async throws -> FormOutcome {
        var errors = FieldErrors()

        let title = (self.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        if title.isEmpty { errors["title"] = "Enter a title." }
        else if title.count > 200 { errors["title"] = "The title must be at most 200 characters." }

        let isbn = (self.isbn ?? "").filter { $0.isNumber || $0 == "X" }
        if isbn.count != 10 && isbn.count != 13 { errors["isbn"] = "Enter a 10 or 13 digit ISBN." }

        let maxYear = Calendar(identifier: .gregorian).component(.year, from: Date()) + 1
        let year = Int((publishedYear ?? "").trimmingCharacters(in: .whitespaces))
        if let year, (1450...maxYear).contains(year) {} else { errors["publishedYear"] = "Enter a year between 1450 and \(maxYear)." }

        var cents: Int?
        let priceText = (price ?? "").trimmingCharacters(in: .whitespaces)
        let parts = priceText.split(separator: ".", omittingEmptySubsequences: false)
        if (1...2).contains(parts.count), let whole = Int(parts[0]), whole >= 0,
           parts.count == 1 || (parts[1].count <= 2 && !parts[1].isEmpty && parts[1].allSatisfy(\.isNumber)) {
            let fraction = parts.count == 2 ? Int(parts[1].padding(toLength: 2, withPad: "0", startingAt: 0)) ?? 0 : 0
            cents = whole * 100 + fraction
        } else {
            errors["price"] = "Enter a price such as 12.50."
        }

        let status = BookStatus(rawValue: self.status ?? "")
        if status == nil { errors["status"] = "Choose a status." }

        var authorID: UUID?
        if let raw = authorId, let id = UUID(uuidString: raw), try await Author.find(id, on: db) != nil {
            authorID = id
        } else {
            errors["authorId"] = "Choose an author."
        }

        let tags = Self.tagNames(from: self.tags, errors: &errors)

        if errors["isbn"] == nil {
            let clash = try await Book.query(on: db).filter(\.$isbn == isbn).first()
            if let clash, clash.id != bookID { errors["isbn"] = "A book with this ISBN already exists." }
        }

        guard errors.isEmpty, let year, let cents, let status, let authorID else { return .invalid(errors) }
        return .valid(BookInput(title: title, isbn: isbn, publishedYear: year, priceCents: cents,
                                  status: status, authorID: authorID, tags: tags))
    }
}

Why the form type is all optional strings:

  • req.content.decode(BookForm.self) uses the URLEncodedFormDecoder for application/x-www-form-urlencoded bodies (and the multipart decoder for multipart/form-data). A field that is missing or of the wrong type makes a stricter type fail decoding with a 400 Bad Request and no information for the user. A browser form submits an empty string for an empty text input, and Int("") is nil, so a publishedYear: Int field would turn every empty field into a decoding error.

  • Optional strings always decode. Validation then decides what is wrong, per field, with a message for people, and the same strings go straight back into the form on a re-render, so the user’s input (including the wrong parts) is not lost.

  • The form type is separate from the model (Book) on purpose. Decoding straight into a model is mass assignment: a hidden extra field in the request could set status or author_id where the form has no such input.

The validation function is plain Swift, not the Validatable protocol. Validatable (see Validation) is the right tool when the body is JSON and the answer is one 400 with every failure listed. Here each message belongs to one input and the rules need the database (the author exists, the ISBN is unique), so a function that returns a dictionary keyed by field name is simpler and testable. Notes on the rules:

  • The ISBN uniqueness check runs a query before the insert, which has a race: two requests can both pass it. The unique constraint on books.isbn in the schema is the real guarantee, and a request that loses the race fails with a database error. Convert that error to the same field message if the race matters for your traffic.

  • Prices are parsed as text to cents so no floating-point value is involved; the editor shows 12.50 and the model stores 1250.

  • \(maxYear) interpolates the current year into the message, so it never goes stale.

Re-rendering with errors: 422, not 200 or a redirect

When validation fails the handler renders the same form with the submitted values and the messages, and sets the status to 422 Unprocessable Entity:

// From WebBooksController.swift
private func invalid(_ req: Request, _ title: String, _ form: BookFormView) async throws -> Response {
    let view = try await req.view.render("books/form", Page(title, req, content: form))
    let response = try await view.encodeResponse(for: req)
    response.status = .unprocessableEntity
    return response
}

Three alternatives are worse. Redirecting back loses the submitted values unless they are stashed in the session. Returning 200 makes the failure invisible to anything but the person reading the page: tests, monitoring, htmx and screen-reader tooling all rely on the status. And throwing Abort(.badRequest) replaces the form with an error page. Browsers render a 422 body exactly like a 200 one. htmx, by default, does not swap 4xx bodies into the page, which is why the layout’s htmx-config adds a rule for 422 (see the htmx section).

The form template is where accessibility is decided. Every input has a visible <label> tied to it by for/id, an invalid input is marked with aria-invalid="true" and points to its message with aria-describedby, the messages sit in elements with stable ids, and a summary at the top (role="alert") lists everything that is wrong in one place:

#extend("base"):
  #export("content"):
    <h1>#(content.heading)</h1>
    #if(content.hasErrors):
    <div class="alert alert-danger" role="alert">
      <p class="mb-1"><strong>The book was not saved. Fix these and try again:</strong></p>
      <ul class="mb-0">
        #for(message in content.errorList):
        <li>#(message)</li>
        #endfor
      </ul>
    </div>
    #endif
    <form method="post" action="#(content.action)" novalidate>
      <input type="hidden" name="_csrf" value="#(csrf)">
      <div class="mb-3">
        <label for="title" class="form-label">Title</label>
        <input id="title" name="title" type="text" required maxlength="200" value="#(content.values.title)"
               class="form-control#if(content.errors.title): is-invalid#endif"
               #if(content.errors.title):aria-invalid="true" aria-describedby="title-error"#endif>
        <div id="title-error" class="invalid-feedback">#(content.errors.title)</div>
      </div>
      <div class="mb-3">
        <label for="isbn" class="form-label">ISBN</label>
        <input id="isbn" name="isbn" type="text" required inputmode="numeric" value="#(content.values.isbn)"
               class="form-control#if(content.errors.isbn): is-invalid#endif" aria-describedby="isbn-help#if(content.errors.isbn): isbn-error#endif"
               #if(content.errors.isbn):aria-invalid="true"#endif>
        <div id="isbn-help" class="form-text">10 or 13 digits; hyphens are ignored.</div>
        <div id="isbn-error" class="invalid-feedback">#(content.errors.isbn)</div>
      </div>
      <div class="row">
        <div class="col-md-6 mb-3">
          <label for="publishedYear" class="form-label">Year published</label>
          <input id="publishedYear" name="publishedYear" type="text" inputmode="numeric" required value="#(content.values.publishedYear)"
                 class="form-control#if(content.errors.publishedYear): is-invalid#endif"
                 #if(content.errors.publishedYear):aria-invalid="true" aria-describedby="year-error"#endif>
          <div id="year-error" class="invalid-feedback">#(content.errors.publishedYear)</div>
        </div>
        <div class="col-md-6 mb-3">
          <label for="price" class="form-label">Price</label>
          <input id="price" name="price" type="text" inputmode="decimal" required value="#(content.values.price)"
                 class="form-control#if(content.errors.price): is-invalid#endif"
                 #if(content.errors.price):aria-invalid="true" aria-describedby="price-error"#endif>
          <div id="price-error" class="invalid-feedback">#(content.errors.price)</div>
        </div>
      </div>
      <div class="mb-3">
        <label for="authorId" class="form-label">Author</label>
        <select id="authorId" name="authorId" required class="form-select#if(content.errors.authorId): is-invalid#endif"
                #if(content.errors.authorId):aria-invalid="true" aria-describedby="author-error"#endif>
          <option value="">Choose...</option>
          #for(option in content.authors):
          <option value="#(option.value)"#if(option.selected): selected#endif>#(option.label)</option>
          #endfor
        </select>
        <div id="author-error" class="invalid-feedback">#(content.errors.authorId)</div>
      </div>
      <fieldset class="mb-3">
        <legend class="form-label fs-6">Status</legend>
        #for(option in content.statuses):
        <div class="form-check form-check-inline">
          <input class="form-check-input" type="radio" name="status" id="status-#(option.value)" value="#(option.value)"#if(option.selected): checked#endif>
          <label class="form-check-label" for="status-#(option.value)">#(option.label)</label>
        </div>
        #endfor
      </fieldset>
      <div class="mb-3">
        <label for="tags" class="form-label">Tags</label>
        <input id="tags" name="tags" type="text" maxlength="400" value="#(content.values.tags)"
               class="form-control#if(content.errors.tags): is-invalid#endif" aria-describedby="tags-help#if(content.errors.tags): tags-error#endif"
               #if(content.errors.tags):aria-invalid="true"#endif>
        <div id="tags-help" class="form-text">Separate tags with commas, for example: classics, computing.</div>
        <div id="tags-error" class="invalid-feedback">#(content.errors.tags)</div>
      </div>
      <button class="btn btn-primary" type="submit">#(content.submitLabel)</button>
      <a class="btn btn-link" href="/books">Cancel</a>
    </form>
  #endexport
#endextend

Details that matter:

  • novalidate on the <form> switches off the browser’s own validation bubbles, so the server’s messages are the single source of truth and look the same everywhere. The required and maxlength attributes stay: they still tell assistive technology and mobile keyboards what the field is, and a script can opt back in to client-side checks later (the HTML and CSS guides of this site cover native validation in Forms, Accessibility and Validation).

  • The error <div> exists for every field and is empty when there is no error; invalid-feedback is only visible when the input has the is-invalid class, so the empty ones take no space. Only an invalid input has aria-describedby="title-error", so a screen reader does not announce an empty description.

  • The status is a radio group inside a <fieldset> with a <legend>, which is how a group of related choices is announced. The author is a <select> whose first option has an empty value, so an untouched selection fails validation instead of silently picking the first author.

  • Link text is unique where it needs to be: the table’s Edit and Delete buttons include the book title in visually hidden text, so a list of links read by a screen reader is not "Edit, Edit, Edit". The details of this checklist are in Web Accessibility and the Bootstrap Accessibility page.

  • Leaf escapes attribute values the same way as text: a title of Second "quoted" appears in value="..." as Second "quoted", which is why the form never breaks out of its attribute.

Tag inputs: a many-to-many field in a form

A book has many tags and a tag many books (the book_tag pivot of the canonical schema). The Kodeco book edits the same kind of relation, its categories, with a multiple <select> enhanced by the Select2 script so that new names can be typed (Condon ch. 14 to 17). The form here uses one plain text input of comma-separated names, which works without a script, and a small function turns that text into the set of tags the book should have:

// Tags.swift
import Fluent
import Vapor

extension BookForm {
    /// Turns "Classics, computing,classics" into ["classics", "computing"]: trimmed, lower-cased, empty parts and
    /// duplicates dropped. Reports a field error for more than 10 tags or a tag longer than 30 characters.
    static func tagNames(from text: String?, errors: inout FieldErrors) -> [String] {
        var names: [String] = []
        for part in (text ?? "").split(separator: ",") {
            let name = part.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
            if !name.isEmpty, !names.contains(name) { names.append(name) }
        }
        if names.count > 10 {
            errors["tags"] = "Use at most 10 tags."
        } else if names.contains(where: { $0.count > 30 }) {
            errors["tags"] = "Each tag can have at most 30 characters."
        }
        return names
    }
}

extension Book {
    /// Makes the book's tags exactly `names`, creating the tags that do not exist yet. Call it in the transaction
    /// that saves the book, so a failure leaves neither the book nor its tags half-written.
    func syncTags(_ names: [String], on db: any Database) async throws {
        let existing = try await Tag.query(on: db).filter(\.$name ~~ names).all()
        var tags = existing
        for name in names where !existing.contains(where: { $0.name == name }) {
            let tag = Tag(name: name)
            try await tag.save(on: db)
            tags.append(tag)
        }
        try await $tags.detachAll(on: db)
        try await $tags.attach(tags, on: db)
    }
}

How it fits into the form:

  • BookForm.tags is one more optional string, and validated calls tagNames(from:errors:), so a bad tag list is one more field message (re-rendered with 422, like the others) and BookInput.tags carries the cleaned names. The template has a labelled text input with a help text, wired to aria-describedby and aria-invalid the same way as the other fields; errorList includes tags so the summary lists it.

  • Names are lower-cased before they are compared or stored. tags.name is unique, and SQLite and PostgreSQL compare text case-sensitively, so without that Classics and classics would become two tags.

  • syncTags loads the existing tags in one IN query (~~ with an array), creates the missing ones, then replaces the book’s pivot rows with $tags.detachAll(on:) and $tags.attach(_:on:). The create and update handlers call it in the same req.db.transaction as the save, so a failure leaves neither the book nor its tags half-written.

  • The edit form shows the current tags joined with ", "; an empty input removes them all. Two people creating the same new tag at the same moment make one save fail on the unique index; retry, or catch the constraint error, if that matters for your traffic.

  • A tag picker with suggestions (a script widget such as Tom Select, or Select2 as in the book) is progressive enhancement on top of this input, as long as it still submits the same comma-separated tags field.

Captured from the scratch package’s test: Classics, computing,classics, ` creates `classics and computing; re-saving with computing, history leaves computing and history on the book (three tags in the table); eleven tags answer 422 with "Use at most 10 tags.".

POST-redirect-GET

After a successful create, update or delete the handler does not render a page. It stores a flash message and answers 303 See Other with a Location:

// From WebBooksController.create
try await req.db.transaction { db in
    try await book.save(on: db)
    try await book.syncTags(input.tags, on: db)
}
req.flash("success", "Created \u{201C}\(book.title)\u{201D}.")
return req.redirect(to: "/books/\(try book.requireID())", redirectType: .normal)

RedirectType.normal is 303 See Other; .permanent is 301 (cached by browsers, never use it after a POST) and .temporary is 307 (the browser repeats the same method and body, which would submit the form again). A 303 makes the browser fetch the target with GET. The result is that the page the user ends on is a GET that can be reloaded, bookmarked and shared, and the Reload button does not offer to "resubmit the form", which is what produces duplicate records when a POST is answered with a page directly. The flash message carries the "Created" notice across the redirect.

The session cookie is created by the first request that touches req.session, and every page does (for the token), so the redirect always carries the cookie the next request needs.

CSRF protection

Cross-site request forgery makes a logged-in user’s browser send a state-changing request the user did not intend, for example a hidden form on another site that posts to /books/<id>/delete. The browser attaches the session cookie automatically, so the request looks authentic. Vapor 4.122 does not include a CSRF middleware, so this section uses the standard defence, the synchroniser token: a random per-session value placed in every form and checked on every state-changing request. The middleware is the same one the Security Hardening page builds and explains, unchanged, so the section has one CSRF implementation: the same session key, the same body handling and the same origin layer.

// CSRF.swift
import Vapor

/// Session-bound CSRF protection for browser forms (synchroniser token pattern).
///
/// Safe methods pass. Every other method must (1) come from an allowed origin and
/// (2) carry the token that is stored in the session, in the `X-CSRF-Token` header or the `_csrf` form field.
struct CSRFMiddleware: AsyncMiddleware {
    static let sessionKey = "csrf-token"
    static let headerName = "X-CSRF-Token"
    static let fieldName = "_csrf"

    /// Origins that may submit state-changing requests, for example `https://bookshelf.example.com`.
    let allowedOrigins: Set<String>

    func respond(to req: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        // Mint the token on the first request of the session so that forms can embed it.
        let token = req.csrfToken

        switch req.method {
        case .GET, .HEAD, .OPTIONS:
            return try await next.respond(to: req)
        default:
            break
        }

        guard OriginPolicy(allowedOrigins: allowedOrigins).allows(req) else {
            throw Abort(.forbidden, reason: "Cross-origin request rejected.")
        }
        let submitted: String?
        if let header = req.headers.first(name: Self.headerName) {
            submitted = header                                   // fetch() and XMLHttpRequest clients
        } else if req.headers.contentType == .urlEncodedForm {
            // Middleware runs before the route collects the body, so collect it here (within the default limit).
            _ = try await req.body.collect(max: req.application.routes.defaultMaxBodySize.value).get()
            submitted = try? req.content.get(String.self, at: Self.fieldName)
        } else {
            submitted = nil
        }
        guard let submitted, constantTimeEqual(submitted, token) else {
            throw Abort(.forbidden, reason: "Missing or invalid CSRF token.")
        }
        return try await next.respond(to: req)
    }
}

extension Request {
    /// The CSRF token of this session. Reading it creates the session (and the token) when needed.
    var csrfToken: String {
        if let existing = session.data[CSRFMiddleware.sessionKey] { return existing }
        let token = Data([UInt8].random(count: 32)).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
        session.data[CSRFMiddleware.sessionKey] = token
        return token
    }
}

/// Compares two strings without returning early at the first difference.
func constantTimeEqual(_ a: String, _ b: String) -> Bool {
    let x = Array(a.utf8), y = Array(b.utf8)
    guard x.count == y.count else { return false }
    var difference: UInt8 = 0
    for (l, r) in zip(x, y) { difference |= l ^ r }
    return difference == 0
}

/// Fetch Metadata and `Origin` checks, the second layer of CSRF defence.
struct OriginPolicy: Sendable {
    let allowedOrigins: Set<String>

    func allows(_ req: Request) -> Bool {
        // Modern browsers say how the request relates to the site.
        if let site = req.headers.first(name: "Sec-Fetch-Site") {
            return site == "same-origin" || site == "none"
        }
        // Older browsers and non-browser clients: fall back to Origin.
        if let origin = req.headers.first(name: .origin) {
            return allowedOrigins.contains(origin)
        }
        // Neither header: not a browser cross-site request (curl, a native app). The token still applies.
        return true
    }
}

WebBooksController registers it as CSRFMiddleware(allowedOrigins: allowedOrigins), with the site’s own origin from PUBLIC_ORIGIN (see configure.swift). How the pieces fit:

  • The token is 32 random bytes from [UInt8].random(count:) (Vapor’s helper over the system random generator), base64url-encoded, stored in req.session.data[CSRFMiddleware.sessionKey] ("csrf-token") and created by the first read of req.csrfToken. The middleware reads it for every request, including GET, so a form rendered by a GET always has a token to embed, and the Page wrapper puts the same value into every template as csrf.

  • The middleware collects the body itself. Vapor collects a request body inside the route’s closure, after all middleware has run, so a middleware that reads req.content straight away sees no body. With a body that arrived in one piece that can go unnoticed; with a chunked post (Transfer-Encoding: chunked) the field is missing and every form gets a 403. For URL-encoded forms the middleware therefore collects the body first, up to the route’s default limit, and then reads the _csrf field.

  • Forms put the token in a hidden _csrf input. For requests that are not form posts, htmx sends it as an X-CSRF-Token header: the layout sets hx-headers on <body>, and htmx adds those headers to every request it issues from inside the body. The middleware accepts either; the header wins.

  • Only GET, HEAD and OPTIONS pass without a token. That is only safe if those methods never change state, which is why deletes and logouts must not be links (a GET that deletes can be triggered by an <img src> on another site, with no token to block it).

  • Before the token, OriginPolicy rejects a request whose Sec-Fetch-Site is not same-origin or none, or whose Origin is not the site’s own; the Security Hardening page explains the two headers.

  • constantTimeEqual compares the bytes without returning at the first difference, the same constant-time pattern as in Middleware. Constant time is best effort in Swift; the main value is that the check is by equality, not by a prefix.

  • The failure is Abort(.forbidden), rendered by HTMLErrorMiddleware as an HTML page. The user’s usual cause is an expired or lost session (a stale form opened before the session cookie expired), so the page text should say what to do, not just "Forbidden".

What the middleware deliberately does not cover, and what to add:

  • Defence in depth. SameSite=Lax on the session cookie already stops the browser from sending it on cross-site POST requests; browsers increasingly treat an unset value as Lax, but not uniformly, so set it explicitly and keep the token and the origin check.

  • Login. After a successful login, rotate the session id with try await req.rotateSession() (the helper from Sessions and Cookies; it keeps the session data, issues a new id and deletes the old record) and reset the CSRF token by setting req.session.data[CSRFMiddleware.sessionKey] = nil, so the next read creates a fresh one. Keeping the pre-login session is session fixation. Do not call req.session.destroy() and then set the user: destroy() invalidates the session, so nothing is saved afterwards. Session Authentication and Web Login shows the whole login flow.

  • Uploads. The middleware collects only URL-encoded bodies, so a multipart/form-data form or a route registered with body: .stream cannot carry the token in a _csrf field. Send it in the X-CSRF-Token header from a small script (as htmx does). Never put it in the query string of the form action: URLs end up in logs, browser history and Referer headers. File Uploads and Streaming covers the streaming case.

  • JSON APIs authenticated by an Authorization header need no CSRF token, because the browser does not attach that header by itself. Routes authenticated by the cookie do need it.

  • The in-memory session store loses the token on restart, so a form opened before a restart posts a token that no longer exists and gets a 403. Production deployments use a shared session driver anyway.

HTML error pages

ErrorMiddleware renders errors as JSON, which is right for an API and unhelpful in a browser. The page-local HTMLErrorMiddleware renders error.leaf instead, but only for clients that want HTML:

import Vapor

/// Renders an HTML error page for browsers. JSON clients get the standard error body.
struct HTMLErrorMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        do {
            return try await next.respond(to: request)
        } catch {
            if request.wantsJSON || request.isHTMX { throw error }
            let status = (error as? any AbortError)?.status ?? .internalServerError
            let reason = status.code < 500 ? ((error as? any AbortError)?.reason ?? status.reasonPhrase) : "Something went wrong."
            request.logger.report(error: error)
            let view = try await request.view.render(
                "error", Page("Error \(status.code)", request, content: ErrorView(status: Int(status.code), reason: reason)))
            let response = try await view.encodeResponse(for: request)
            response.status = status
            return response
        }
    }
}
#extend("base"):
  #export("content"):
    <h1>#(content.status)</h1>
    <p>#(content.reason)</p>
    <a href="/books">Back to the catalogue</a>
  #endexport
#endextend

The middleware rethrows for JSON clients and for htmx requests (htmx would otherwise swap a full HTML page into a fragment of the page), so those still get the standard error body from ErrorMiddleware. For a 5xx it shows a generic sentence and writes the real error to the log with logger.report(error:), so internal details never reach the page. Because it is registered as the last of the user middleware, an unknown URL (RouteNotFound) and an Abort(.notFound) from a handler both end here. If rendering the error page itself fails (for example, a template is missing), the exception propagates to ErrorMiddleware and the client gets the JSON error; keep the error template as simple as it is.

Styling with Bootstrap 5

The layout loads Bootstrap’s CSS and its bundle (Popper included) from jsDelivr with subresource integrity: the integrity attribute holds a SHA-384 hash of the exact file, and the browser refuses to apply a file whose hash differs, so a compromised CDN cannot silently change the code the page runs. Bootstrap 5 does not need jQuery. The hashes in the layout were computed from the files served for the pinned URLs:

curl -s https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css | openssl dgst -sha384 -binary | openssl base64 -A

Pin the version in the URL (never @5 or @latest, because the content, and so the hash, would change), keep crossorigin="anonymous" (SRI on a cross-origin file needs it), and recompute the hash whenever the version changes. The alternative is to serve the files yourself: copy bootstrap.min.css and the bundle into Public/, reference them with /bootstrap.min.css, and let FileMiddleware serve them (Static Files and File I/O). That removes the third-party request and works offline, at the cost of updating the copies yourself, and a Content-Security-Policy of default-src 'self' becomes possible. If you stay on the CDN, a CSP must allow that host and should use the hashes, and the inline <script> blocks that Bootstrap examples sometimes use must be avoided; this page uses none.

The markup in the templates is plain Bootstrap: container, navbar, table, form-control, form-select, form-check, alert, badge and pagination. Bootstrap itself is covered by the Bootstrap section of this site, its forms and form validation pages in particular.

Progressive enhancement with htmx

Everything above works with JavaScript disabled. htmx is added as attributes on the existing markup, and the server answers a plain request with a page and an htmx request with a fragment. The two enhancements on the list page:

  • Live search. The search input has hx-get="/books", hx-trigger="input changed delay:300ms, search", hx-target="#book-table" and hx-push-url="true": while typing, htmx requests /books?q=... (after a 300 ms pause) and replaces the table. hx-sync="this:replace" cancels an older request when a newer one starts. hx-push-url updates the address bar so the search can be reloaded and shared. Without JavaScript the same input is in a real GET form with a submit button, and the server renders the full page.

  • Delete in place. The delete button has hx-delete="/books/<id>", hx-confirm="Delete this book?", hx-target="closest tr" and hx-swap="outerHTML". The server deletes and answers an empty 200, so the row is replaced with nothing. Without JavaScript the detail page offers a real form that posts to /books/<id>/delete.

On the server, req.isHTMX (the HX-Request: true header, which htmx adds to every request it makes) chooses the fragment template. Three details are easy to get wrong:

  • Vary. A URL that answers with a page or a fragment depending on a header must say so: the handler sends Vary: Accept, HX-Request. Otherwise the browser may reuse the fragment as the page when the user presses Back.

  • History restoration. When the user navigates back to a URL the htmx cache no longer holds, htmx re-requests it and expects a full page. The layout sets historyRestoreAsHxRequest to false, and isHTMX also checks that HX-History-Restore-Request is not true, so such a request gets the page, not the fragment.

  • 4xx responses. In htmx 2, responses with 4xx and 5xx status codes are not swapped by default. The htmx-config meta tag in the layout adds a rule so that 422 is swapped, which is what lets a failed htmx form submission show its error messages. Other client errors still go to htmx’s error events, as the htmx documentation describes.

Two limits of what was checked here. The htmx attributes and configuration follow the official documentation (version 2.0.11, current at the time of writing); the browser behaviour was not run in this environment. And htmx forms are best added to an application that already works as plain HTML, as here, so that a failure of the script degrades to a working page instead of a broken one.

The token travels in the X-CSRF-Token header, taken from hx-headers on <body>. A request that omits it fails with 403, as the curl transcript shows.

Running it

The scratch package was run on SQLite in memory (swift run App serve) and driven with curl and a cookie jar. The transcript, abridged to the status lines and the interesting headers, with the session and the CSRF token carried from request to request:

$ curl -si -X POST localhost:8099/books -d title=x   # no session, no token
HTTP/1.1 403 Forbidden
content-type: text/html; charset=utf-8

$ curl -si -b jar -X POST localhost:8099/books -d _csrf=... -d title= -d isbn=12 ...   # invalid
HTTP/1.1 422 Unprocessable Entity
content-type: text/html; charset=utf-8

$ curl -si -b jar -X POST localhost:8099/books -d _csrf=... (valid)
HTTP/1.1 303 See Other
content-length: 0
location: /books/8E055189-6610-40D7-8DF3-E7573E492039

$ curl -s -b jar localhost:8099/books/8E055189-6610-40D7-8DF3-E7573E492039 | grep -m1 -A0 'role="status"'
    <div class="alert alert-success" role="status">Created “Swift on the Server”.</div>

$ same URL again
0

$ curl -si -H "Accept: application/json" localhost:8099/books/8E055189-6610-40D7-8DF3-E7573E492039
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 180
vary: Accept
{"title":"Swift on the Server","publishedYear":2024,"status":"published","author":"Ada Lovelace","isbn":"9781402894626","price":"29.90","id":"8E055189-6610-40D7-8DF3-E7573E492039"}
$ curl -si -H "HX-Request: true" "localhost:8099/books?q=swift" | head
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 1427
vary: Accept, HX-Request
connection: keep-alive
date: Sat, 17 Oct 2026 16:06:24 GMT



$ update
HTTP/1.1 303 See Other
content-length: 0
location: /books/8E055189-6610-40D7-8DF3-E7573E492039
    <div class="alert alert-success" role="status">Saved “Swift on the Server, 2nd ed.”.</div>

$ delete (plain form POST)
HTTP/1.1 303 See Other
content-length: 0
location: /books
    <div class="alert alert-success" role="status">Deleted “Swift on the Server, 2nd ed.”.</div>

$ htmx-style DELETE with the token header
HTTP/1.1 200 OK
content-length: 0
HTTP/1.1 403 Forbidden

What the transcript shows, in order: a POST without a session or token is refused with 403; an invalid form is answered with 422 and the page; a valid one is answered with 303 and a Location; following the redirect shows the flash message, and a second request to the same URL shows none; the JSON representation of the same URL carries Vary: Accept; the htmx request gets a fragment; editing and deleting also redirect with a message; the htmx-style DELETE works with the header and fails without it.

Testing the pages

VaporTesting (see Testing) runs the whole chain in-process, including the sessions and CSRF middleware, so the tests read exactly what a browser would: the Set-Cookie from a first GET, the token in the HTML, then a POST carrying both. The helper methods collect the cookie and the token once and the tests reuse them:

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite(.serialized)
struct WebBooksTests {
    /// A started application with one author, and a helper that carries the session cookie between requests.
    func withApp(_ body: (Application, UUID) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            let author = Author(name: "Test Author")
            try await author.save(on: app.db)
            try await body(app, author.requireID())
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    /// GET a page and return the session cookie header value and the CSRF token embedded in it.
    func startSession(_ app: Application) async throws -> (cookie: String, token: String) {
        var cookie = "", token = ""
        try await app.testing().test(.GET, "books/new") { res async in
            cookie = res.headers.setCookie?["bookshelf-session"]?.string ?? ""
            let body = res.body.string
            if let r = body.range(of: #"name="_csrf" value="([^"]+)""#, options: .regularExpression) {
                token = String(body[r]).replacingOccurrences(of: #"name="_csrf" value=""#, with: "").replacingOccurrences(of: "\"", with: "")
            }
        }
        return (cookie, token)
    }

    func form(_ pairs: [(String, String)]) -> ByteBuffer {
        var allowed = CharacterSet.alphanumerics
        allowed.insert(charactersIn: "-._~")
        let text = pairs.map { "\($0.0)=\($0.1.addingPercentEncoding(withAllowedCharacters: allowed) ?? "")" }.joined(separator: "&")
        return ByteBuffer(string: text)
    }

    func headers(cookie: String, extra: [(String, String)] = []) -> HTTPHeaders {
        var h: HTTPHeaders = ["Content-Type": "application/x-www-form-urlencoded", "Cookie": "bookshelf-session=\(cookie)"]
        for (k, v) in extra { h.add(name: k, value: v) }
        return h
    }

    @Test func postWithoutTokenIsForbidden() async throws {
        try await withApp { app, authorID in
            let (cookie, _) = try await startSession(app)
            try await app.testing().test(.POST, "books", headers: headers(cookie: cookie),
                                         body: form([("title", "No token")])) { res async in
                #expect(res.status == .forbidden)
            }
        }
    }

    @Test func createRedirectsAndFlashesOnce() async throws {
        try await withApp { app, authorID in
            let (cookie, token) = try await startSession(app)
            var location = ""
            let fields = [("_csrf", token), ("title", "Swift on the Server"), ("isbn", "978-1-4028-9462-6"),
                          ("publishedYear", "2024"), ("price", "29.9"), ("status", "published"), ("authorId", authorID.uuidString)]
            try await app.testing().test(.POST, "books", headers: headers(cookie: cookie), body: form(fields)) { res async in
                #expect(res.status == .seeOther)
                location = res.headers.first(name: .location) ?? ""
            }
            #expect(location.hasPrefix("/books/"))
            let getHeaders: HTTPHeaders = ["Cookie": "bookshelf-session=\(cookie)"]
            try await app.testing().test(.GET, location, headers: getHeaders) { res async in
                #expect(res.status == .ok)
                #expect(res.body.string.contains("Created"))
                #expect(res.body.string.contains("29.90"))
            }
            try await app.testing().test(.GET, location, headers: getHeaders) { res async in
                #expect(!res.body.string.contains("Created"))
            }
        }
    }

    @Test func tagInputCreatesAndSyncsTags() async throws {
        try await withApp { app, authorID in
            let (cookie, token) = try await startSession(app)
            var location = ""
            let base = [("_csrf", token), ("title", "Tagged"), ("isbn", "9781402894626"), ("publishedYear", "2024"),
                        ("price", "10"), ("status", "published"), ("authorId", authorID.uuidString)]
            try await app.testing().test(.POST, "books", headers: headers(cookie: cookie),
                                         body: form(base + [("tags", "Classics, computing,classics, ")])) { res async in
                #expect(res.status == .seeOther)
                location = res.headers.first(name: .location) ?? ""
            }
            let book = try #require(try await Book.query(on: app.db).filter(\.$title == "Tagged").first())
            #expect(try await book.$tags.get(on: app.db).map(\.name).sorted() == ["classics", "computing"])
            try await app.testing().test(.GET, location + "/edit", headers: ["Cookie": "bookshelf-session=\(cookie)"]) { res async in
                #expect(res.body.string.contains(#"value="classics, computing""#))
            }
            try await app.testing().test(.POST, location, headers: headers(cookie: cookie),
                                         body: form(base + [("tags", "computing, history")])) { res async in
                #expect(res.status == .seeOther)
            }
            #expect(try await book.$tags.get(reload: true, on: app.db).map(\.name).sorted() == ["computing", "history"])
            #expect(try await Tag.query(on: app.db).count() == 3)
            let tooMany = (1...11).map { "t\($0)" }.joined(separator: ",")
            try await app.testing().test(.POST, location, headers: headers(cookie: cookie),
                                         body: form(base + [("tags", tooMany)])) { res async in
                #expect(res.status == .unprocessableEntity)
                #expect(res.body.string.contains("Use at most 10 tags."))
            }
        }
    }

    @Test func invalidFormIs422AndKeepsValues() async throws {
        try await withApp { app, authorID in
            let (cookie, token) = try await startSession(app)
            let fields = [("_csrf", token), ("title", ""), ("isbn", "12"), ("publishedYear", "abc"),
                          ("price", "1.234"), ("status", "draft"), ("authorId", "")]
            try await app.testing().test(.POST, "books", headers: headers(cookie: cookie), body: form(fields)) { res async in
                #expect(res.status == .unprocessableEntity)
                let body = res.body.string
                #expect(body.contains("Enter a title."))
                #expect(body.contains("Enter a 10 or 13 digit ISBN."))
                #expect(body.contains(#"value="abc""#))
                #expect(body.contains(#"aria-invalid="true""#))
            }
        }
    }

    @Test func titleIsHTMLEscaped() async throws {
        try await withApp { app, authorID in
            let book = Book(title: "<script>x</script>", isbn: "9780306406157", publishedYear: 2000, priceCents: 100, authorID: authorID)
            try await book.save(on: app.db)
            try await app.testing().test(.GET, "books/\(try book.requireID())") { res async in
                #expect(!res.body.string.contains("<script>x"))
                #expect(res.body.string.contains("&lt;script&gt;x&lt;/script&gt;"))
            }
        }
    }

    @Test func oneUrlTwoRepresentations() async throws {
        try await withApp { app, authorID in
            let book = Book(title: "Negotiated", isbn: "9780306406157", publishedYear: 2000, priceCents: 100, authorID: authorID)
            try await book.save(on: app.db)
            let path = "books/\(try book.requireID())"
            try await app.testing().test(.GET, path, headers: ["Accept": "text/html,application/xhtml+xml,*/*;q=0.8"]) { res async in
                #expect(res.headers.contentType == .html)
            }
            try await app.testing().test(.GET, path, headers: ["Accept": "application/json"]) { res async throws in
                #expect(res.headers.contentType == .json)
                #expect(res.headers.first(name: .vary) == "Accept")
                #expect(try res.content.decode(BookDTO.self).title == "Negotiated")
            }
            try await app.testing().test(.GET, path, headers: ["Accept": "*/*"]) { res async in
                #expect(res.headers.contentType == .html)
            }
        }
    }

    @Test func htmxGetsOnlyThePartial() async throws {
        try await withApp { app, authorID in
            try await app.testing().test(.GET, "books", headers: ["HX-Request": "true"]) { res async in
                #expect(!res.body.string.contains("<html"))
            }
            try await app.testing().test(.GET, "books") { res async in
                #expect(res.body.string.contains("<html"))
            }
        }
    }

    @Test func htmlErrorPageForBrowsersJSONForClients() async throws {
        try await withApp { app, _ in
            try await app.testing().test(.GET, "books/00000000-0000-0000-0000-000000000000") { res async in
                #expect(res.status == .notFound)
                #expect(res.headers.contentType == .html)
            }
            try await app.testing().test(.GET, "books/00000000-0000-0000-0000-000000000000", headers: ["Accept": "application/json"]) { res async in
                #expect(res.headers.contentType == .json)
            }
        }
    }
}

The eight tests cover: the token check, create with redirect and a flash that appears exactly once, the tag input (cleaning, creating and re-syncing tags, and the limit), the 422 response with preserved values and ARIA attributes, HTML escaping of a hostile title, the two representations and the wildcard rule of one URL, the htmx fragment against the full page, and the HTML versus JSON error page. Output of the run:

✔ Test postWithoutTokenIsForbidden() passed after 0.020 seconds.
✔ Test createRedirectsAndFlashesOnce() passed after 0.019 seconds.
✔ Test tagInputCreatesAndSyncsTags() passed after 0.023 seconds.
✔ Test invalidFormIs422AndKeepsValues() passed after 0.012 seconds.
✔ Test titleIsHTMLEscaped() passed after 0.009 seconds.
✔ Test oneUrlTwoRepresentations() passed after 0.013 seconds.
✔ Test htmxGetsOnlyThePartial() passed after 0.010 seconds.
✔ Test htmlErrorPageForBrowsersJSONForClients() passed after 0.008 seconds.
✔ Suite WebBooksTests passed after 0.117 seconds.
✔ Test run with 8 tests in 1 suite passed after 0.117 seconds.

Rendered-HTML assertions by substring are brittle for layout and fine for the facts that matter (an escaped title, an aria-invalid attribute, a message). To check the whole accessibility tree and the real htmx behaviour, drive a browser with the tools of the e2e testing page of this site, End-to-End Testing in the Browser.

Swift 6 concurrency notes

  • A route handler is a @Sendable function, as in the controller. It may use self only when the controller is Sendable (a struct without mutable state is).

  • Request is a final class that conforms to Sendable in 4.122.2 (its mutable state is behind locks), so capturing it in a Task compiles. It still belongs to one request: a task that outlives the handler would use a request whose response has gone and whose session has been saved. The handlers read what they need and build Sendable view models before rendering, and anything that runs later gets copied values, not req.

  • Page<Content> requires Content: Encodable & Sendable, so a view model that holds a non-Sendable reference (a Fluent model, which is @unchecked Sendable in this package, or a class) is a deliberate decision, not an accident. View models here are struct`s of `String, Int, UUID and Bool.

  • FlashMessage, BookForm, BookInput, FormOutcome and the middleware structs are Sendable by construction, so nothing needed @unchecked. The FormOutcome enum replaces Result<BookInput, FieldErrors> because the failure type of Result must conform to Error, and a dictionary does not.

  • Session access (req.session.data) is synchronous and tied to the request. Reading and writing it before and after an await in the same handler is fine; passing the session to another task is not.

  • Leaf rendering is asynchronous (req.view.render is async), so a handler that renders must be async; there is no blocking call on an event loop.

Common errors and pitfalls

Symptom Cause Fix

LeafError "unable to match TagDeclaration" for empty and endfor

#empty is not a tag in Leaf 4

test the collection with #if(count(items) == 0)

LexerError ... invalidParameterToken("?")

a ternary in a template; LeafKit 1.14.3 does not parse it

compute the value in the view model, or use #if/#else

Every form post gets 403

no _csrf field, the session cookie is missing (cookie blocked, Secure cookie over plain HTTP, server restarted with the memory driver), or the middleware is registered before the sessions middleware

embed the token in every form; check Set-Cookie; register sessions first

Edits to a template are not visible

app.leaf.cache.isEnabled is true, or the process runs from another working directory

disable the cache in development; run from the project root or set app.directory.workingDirectory

Resources/Views not found, "unable to find template"

the working directory is not the project root (an Xcode scheme without the working directory set)

set the scheme’s working directory, or point app.leaf.sources at an absolute path

The browser shows the JSON version of a page after going Back

no Vary: Accept on the negotiated URL

add Vary; see varying(_:)

The htmx request returns a whole page nested inside the table

the handler ignored HX-Request, or the history-restore request was answered with a fragment

branch on req.isHTMX; keep historyRestoreAsHxRequest false

A failed htmx form shows nothing

htmx does not swap 4xx bodies by default

add the 422 rule to htmx.config.responseHandling

Int field fails decoding for an empty input, with a bare 400

the form type used Int instead of an optional String

decode as String?, validate, convert

Reload after saving shows "Resubmit the form?"

the POST handler rendered a page instead of redirecting

answer 303 See Other and show the result with a GET

Deleting works from another site’s page

deletes are GET links, or CSRF is not checked on that route

use POST/DELETE and put the route behind the CSRF group

The flash message appears twice, or never

it was read by two renders in one request, or queued on a path that errored

read it once in Page.init; flash only on success

The search term breaks the pager links

the term was written raw into href

percent-encode it (encodedQ)

User text is rendered as HTML

#unsafeHTML or building HTML strings in Swift

let #(value) escape; sanitise at input if rich text is needed

The CDN stylesheet is blocked, with an SRI error in the console

the hash does not match the pinned file, crossorigin is missing, or the URL was changed without recomputing the hash

recompute the integrity value

Sessions disappear with several instances

the memory driver is per process

use the Fluent or Redis driver

Version notes and outdated material

  • This page uses the Vapor 4.122.x APIs: app.views.use(.leaf), req.view.render, app.sessions.use(.memory), app.sessions.configuration, app.middleware.use, req.redirect(to:redirectType:), req.content.decode, Page/Encodable contexts and the VaporTesting app.testing().test.

  • Leaf 4 (4.5.2 here) uses #extend/#export/#import for layouts. Leaf 2 and 3 used #embed and #set/#get for the same purposes, a different #if(x) { ... } brace syntax and a LeafProvider registered through services; none of that exists now.

  • req.view.render in older books returns an EventLoopFuture<View>. It is async throws now, and the View is converted to a response with encodeResponse(for:) when the handler needs to change the status or headers.

  • Vapor 5 (5.0.0-beta.4, not released) is a major version. Its routing, middleware and content APIs change in places, and Vapor 5 introduces a new View type, so check how Leaf plugs into it; review What’s New, Upgrading and Vapor 5 before moving and re-test the forms.

  • htmx 2 differs from htmx 1 in its handling of error responses (the responseHandling configuration, used above) and in moving the WebSocket and SSE attributes into extensions. The htmx documentation recommends setting historyRestoreAsHxRequest to false for servers that answer partials. Tutorials written for htmx 1 do not mention either.

  • Bootstrap 5 dropped the jQuery requirement of Bootstrap 4; books that show Bootstrap 4 markup (form-group, custom-select, data-toggle, .sr-only) need the 5.x names (mb-3, form-select, data-bs-toggle, .visually-hidden).

Where the books are outdated

  • The Kodeco book (Condon et al., chapters 16 and 17) and Yeung (chapters 6 and 7) build the same kind of CRUD site on Leaf 2 or early Leaf 4 with future-returning handlers (req.view().render, .flatMap) and Content-decoding forms. The concept of POST-redirect-GET, the form-and-redirect cycle and flash messages carries over; the signatures, the Leaf tag syntax and the registration code do not, as the version notes list.

  • None of the three books protects its forms against CSRF consistently: the Kodeco and Yeung forms post with only the session cookie, and Patel’s content-negotiation chapter (chapter 7) has a JSON and a browser interface on the same routes with no token. Treat the token, SameSite and the origin check as required for any cookie-authenticated form.

  • Logout and delete as GET links, validation text carried in the query string, and a template name taken from the URL, all of which appear in the books' sample code, are each a vulnerability or a bug: use POST, keep messages in the session (the flash mechanism above) and choose templates in code.

  • Patel’s chapter 7 shows content negotiation by setting the response type from the request; today’s Accept parsing is headers.accept.comparePreference(for:to:), and the Vary header, which the books do not set, is needed for correct caching.