Vapor + Htmx Todo App


Introduction

This post is a quick example of creating a very basic todo web application using Vapor a swift web framework and Htmx, with no custom javascript required.

Vapor

Htmx

Getting Started

To get started you must install the vapor command-line tool that will generate our project.

brew install vapor

Next, generate the project using the vapor command-line tool.

vapor new todo-htmx --fluent.db sqlite --leaf

The above command will generate a new project that uses an SQLite database along with vapor’s Leaf templating engine. You can move into the project directory and browse around the files that are generated.

cd todo-htmx

Update the Controller

Open the Sources/App/Controllers/TodoController.swift file. This file handles the api routes for our Todo database model. Personally I like to prefix these routes with api.

Update the first line in the boot(routes: RoutesBuilder) function to look like this.

let todos = routes.grouped("api", "todos")

Everything else can stay the same. This changes these routes to be exposed at http://localhost:8080/api/todos, which will allow our routes that return html views to be able to be exposed at http://localhost:8080/todos.

Update the Todo Model

A todo is not very valuable without a way to tell if it needs to be completed or not. So, let’s add a field to our database model (Sources/App/Models/Todo.swift).

Update the file to include the following:

import Fluent
import struct Foundation.UUID

/// Property wrappers interact poorly with `Sendable` checking, causing a warning for the `@ID` property
/// It is recommended you write your model with sendability checking on and then suppress the warning
/// afterwards with `@unchecked Sendable`.
final class Todo: Model, @unchecked Sendable {
  static let schema = "todos"

  @ID(key: .id)
  var id: UUID?

  @Field(key: "title")
  var title: String

  @Field(key: "complete")
  var complete: Bool

  init() {}

  init(id: UUID? = nil, title: String, complete: Bool) {
    self.id = id
    self.title = title
    self.complete = complete
  }

  func toDTO() -> TodoDTO {
    .init(
      id: id,
      title: $title.value,
      complete: $complete.value
    )
  }
}

Since we added a field to our database model, we also need to update the migration file (Sources/App/Migrations/CreateTodo.swift).

import Fluent

struct CreateTodo: AsyncMigration {
  func prepare(on database: Database) async throws {
    try await database.schema("todos")
      .id()
      .field("title", .string, .required)
      .field("complete", .bool, .required)
      .create()
  }

  func revert(on database: Database) async throws {
    try await database.schema("todos").delete()
  }
}

This just adds our new field to the database schema when we run the migrations, which we will do later on in the tutorial.

Update the Data Transfer Object

We also need to add the complete field to our data transfer object (DTO). This model is used as an intermediate between our database and the user.

import Fluent
import Vapor

struct TodoDTO: Content {
  var id: UUID?
  var title: String?
  var complete: Bool?

  func toModel() -> Todo {
    let model = Todo()

    model.id = id
    model.complete = complete ?? false
    if let title = title {
      model.title = title
    }
    return model
  }
}

Generate the View Templates

Our index template was already generated at Resources/Views/index.leaf, open the file and edit the contents to match the following.

Note: You can learn more about the leaf templating engine here.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>#(title)</title>
    <link rel="stylesheet" href="css/main.css" />
    <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  </head>

  <body>
    <h1>#(title)</h1>
    <div class="container">
      <form hx-post="/todos" hx-target="#todos">
        <label for="title">Todo</label>
        <input type="text" name="title" placeholder="Title" />
        <button type="submit">Submit</button>
      </form>

      <!-- Todos List -->
      <table id="todos" class="todos" hx-get="/todos" hx-trigger="load"></table>
    </div>
  </body>
</html>

The important parts here are the <script> tag in the head element which will include Htmx in our project.

The head element also contains a link to a custom css stylesheet that we will create shortly.

We add a form element that will be used to generate a new todo item in the database. This is a basic / standard html form, but we are using Htmx to post the form contents to the route POST http://localhost:8080/todos, which we will create shortly.

Then there’s the table element that will contain the contents of our todos. When the page is loaded it will use Htmx to fetch the todos from GET http://localhost:8080/todos route, which we will create shortly.

Todos Table Template

Create a new view template that will return our populated table of todos.

touch Resources/Views/todos.leaf

The contents of this file should be the following:

<!-- Template for a list of todo's -->
<table id="todos"
    class="todos">
  <!-- The table header -->
  <tr>
    <th>Description</th>
    <th>Completed</th>
    <th></th>
  </tr>

  #for(todo in todos):
  <tr>
    <!-- Make the title column take up 90% of the width -->
    <td style="width: 90%;">#(todo.title)</td>
    <td>
      <input type="checkbox"
           id="todo_#(todo.id)"
           hx-put="/todos/#(todo.id)"
           hx-trigger="click"
           hx-target="#todos"
           #if(todo.complete): checked #endif>
      </input>
    </td>
    <td>
      <button hx-delete="/todos/#(todo.id)"
              hx-trigger="click"
              hx-target="#todos"
              class="btn-delete-todo">
        X
      </button>
    </td>
  </tr>
  #endfor
</table>

Here, we just create a table that is 3 columns wide from a list of todos that we will pass in to the template. We use Htmx to handle updating a todo if a user clicks a checkbox to mark the todo as complete, we also add a button in the last column of the table that we use Htmx to handle deleting a todo from the database.

Controllers

The controllers handle the routes that our website exposes. The project template creates a controller for us that handles JSON / API requests, but we do need to make a couple of changes to the file (Sources/App/Controllers/TodoController.swift).

import Fluent
import Vapor

struct TodoController: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
    let todos = routes.grouped("api", "todos")

    todos.get(use: index)
    todos.post(use: create)
    todos.group(":todoID") { todo in
      todo.delete(use: self.delete)
      todo.put(use: self.update)
    }
  }

  @Sendable
  func index(req: Request) async throws -> [TodoDTO] {
    try await Todo.query(on: req.db).all().map { $0.toDTO() }
  }

  @Sendable
  func create(req: Request) async throws -> TodoDTO {
    let todo = try req.content.decode(TodoDTO.self).toModel()

    try await todo.save(on: req.db)
    return todo.toDTO()
  }

  @Sendable
  func delete(req: Request) async throws -> HTTPStatus {
    guard let todo = try await Todo.find(req.parameters.get("todoID"), on: req.db) else {
      throw Abort(.notFound)
    }

    try await todo.delete(on: req.db)
    return .noContent
  }

  @Sendable
  func update(req: Request) async throws -> TodoDTO {
    // let todo = try req.content.decode(TodoDTO.self).toModel()
    guard let todo = try await Todo.find(req.parameters.get("todoID"), on: req.db) else {
      throw Abort(.notFound)
    }
    todo.complete.toggle()
    try await todo.save(on: req.db)
    return todo.toDTO()
  }

}

The primary changes here are to add the update(req: Request) function at the bottom, which handles updating a todo that has already been created. This will be used when a user clicks on the checkbox to mark a todo as complete or incomplete.

We also change the route in the boot(routes: RoutesBuilder) method to make all these routes accessible at /api/todos instead of the original /todos as we will use the /todos routes for returning our views from our view controller.

Todo View Controller

Next we need to create our view controller, it is what will be used to handle routes that should return html content for our website. This controller will actually use the api controller to do the majority of it’s work.

The easiest thing is to make a copy of the current api controller:

cp Sources/App/Controllers/TodoController.swift Sources/App/Controllers/TodoViewController.swift

Then update the file to the following:

import Fluent
import Vapor

struct TodoViewController: RouteCollection {

  private let api = TodoController()

  func boot(routes: RoutesBuilder) throws {
    let todos = routes.grouped("todos")

    todos.get(use: index)
    todos.post(use: create)
    todos.group(":todoID") { todo in
      todo.delete(use: self.delete)
      todo.put(use: self.update)
    }
  }

  @Sendable
  func index(req: Request) async throws -> View {
    let todos = try await api.index(req: req)
    return try await req.view.render("todos", ["todos": todos])
  }

  @Sendable
  func create(req: Request) async throws -> View {
    _ = try await api.create(req: req)
    return try await index(req: req)
  }

  @Sendable
  func delete(req: Request) async throws -> View {
    _ = try await api.delete(req: req)
    return try await index(req: req)
  }

  @Sendable
  func update(req: Request) async throws -> View {
    _ = try await api.update(req: req)
    return try await index(req: req)
  }
}

Here we use the api controller to do the heavy lifting of communicating with the database, then we just always return / render the todos.leaf template that we created earlier, which will update our web page with the list of todos retreived from the database.

Note: There are better ways to handle this, however this is just a simple example.

Update our routes

Next, we need to tell vapor to use our new view controller (Sources/App/routes.swift)

import Fluent
import Vapor

func routes(_ app: Application) throws {
  app.get { req async throws in
    try await req.view.render("index", ["title": "Todos"])
  }

  app.get("hello") { _ async -> String in
    "Hello, world!"
  }

  try app.register(collection: TodoController())
  try app.register(collection: TodoViewController())
}

Here, we just add the TodoViewController at the bottom so vapor will be able to handle those routes and also update the title to be Todos (in the first app.get near the top).

Build and Run

At this point we should be able to build and run the application.

First, let’s make sure the project builds.

swift build

This may take a minute if it’s the first time building the project as it has to fetch the dependencies. If you experience problems here then make sure you don’t have typos in your files.

Next, we need to run the database migrations.

swift run App migrate

Finally, we can run the application.

swift run App

You should be able to open your browser and type in the url: http://localhost:8080 to view the application. You can experiment with adding a new todo using the form.

Note: To stop the application use Ctrl-c

Bonus Styles

Hopefully you weren’t blinded the first time you opened the application. You can add custom styles by creating a css file (Public/css/main.css).

mkdir Public/css
touch Public/css/main.css

Update the file to the following:

body {
  background-color: #1e1e2e;
  color: #ff66ff;
}

table {
  width: 100%;
}

th,
td {
  border-bottom: 1px solid grey;
  border-collapse: collapse;
}

td {
  color: white;
}

.todos {
  transition: all ease-in 1s;
}

.btn-delete-todo {
  color: red;
  margin-left: 20px;
}

Currently vapor does not know to serve files from the Public directory, so we need to update the Sources/App/configure.swift file, by uncommenting the line near the top.

import Fluent
import FluentSQLiteDriver
import Leaf
import NIOSSL
import Vapor

// configures your application
public func configure(_ app: Application) async throws {
  // uncomment to serve files from /Public folder
  app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))

  app.databases.use(DatabaseConfigurationFactory.sqlite(.file("db.sqlite")), as: .sqlite)

  app.migrations.add(CreateTodo())

  app.views.use(.leaf)

  // register routes
  try routes(app)
}

Now you can stop and restart to see the styled website.

swift run App

Conclusion

I hope you enjoyed this quick example of using Htmx with Vapor. You can view the source files at here .


Creative Commons Licence
This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.