DEV Community

Cover image for Building Serverless Apps with Spin and htmx
Thorsten Hans for Fermyon

Posted on • Updated on

Building Serverless Apps with Spin and htmx

Hi,

In this article, I’ll walk you through the process of building serverless applications using the power of WebAssembly with Fermyon Spin and htmx. For demonstration purposes, we will build a simple shopping list. We will implement the serverless backend in Rust. For building the frontend, we’ll start with a simple HTML page, which we will enhance using htmx.

Before we dive into implementing the sample application, we will ensure that everybody is on track and quickly recap what Fermyon Spin and htmx actually are.

What is Fermyon Spin

Fermyon Spin (Spin) is an open-source framework for building and running event-driven, serverless applications based on WebAssembly (Wasm).

Developers can build applications using a wide variety of different programming languages (basically developers can choose from all languages that could be compiled to Wasm). Spin uses Wasm because the core value propositions of Wasm perfectly address common non-functional requirements that we face when building distributed applications:

  • Near-native runtime performance
  • Blazingly fast cold start times (speaking about milliseconds here) that allow scale-down to zero
  • Strict sandboxing and secure isolation

At Fermyon we're obsessed when it comes to developer productivity. The spin CLI and the language-specific SDKs ensure unseen developer productivity in the realm of WebAssembly.

What is htmx

htmx is a lightweight JavaScript library built for developers, facilitating progressive enhancements in web applications. By seamlessly updating specific parts of a webpage without necessitating a complete reload, htmx empowers us to create dynamic and interactive user experiences effortlessly. Its simplicity and performance-oriented approach, utilizing HTML attributes for defining behavior, make it a valuable tool for augmenting existing projects or crafting new frontends.

The sample application

For demonstration purposes, we will create a simple shopping list to illustrate how Spin and a simple frontend crafted with htmx could be integrated. From an architectural point of view, our shopping list consists of three major components, as shown in the diagram below.

  1. Persistence: We use SQLite as a database to persist the items of our shopping list
  2. Backend: We will build an HTTP API using Rust and Spin
  3. Frontend: The frontend consists of HTML, CSS, and progressive enhancements provided by htmx

Overall Architecture for the Spin & htmx Shopping List

You can find the source of sample application on GitHub at https://github.com/ThorstenHans/spin-htmx.

Prerequisites

To follow along with the sample explained in this article, you need to have the following tools installed on your machine:

  1. Rust - See installation instructions for Rust (I am currently using Rust version 1.75.0)
  2. The wasm32-wasi compilation target - You can install it using the rustup target add wasm32-wasi command
  3. The spin CLI - See installation instructions for Spin (I am currently using spin version 2.1.0)

Creating the app skeleton

First, we will use the spin CLI to create our Spin application and add the following components to it:

  • app: A Spin component based on the static-fileserver template which will serve our frontend
  • api: A Spin component based on the http-rust template, which will serve the HTTP API

Because the spin CLI is built with productivity in mind, generating the entire boilerplate is quite easy, as you can see in this snippet:

Because the spin CLI is built with productivity in mind, generating the entire boilerplate is quite easy, as you can see in this snippet:

# Create an empty Spin app called shopping-list
spin new -t http-empty shopping-list
Description: A shopping list built with Spin and htmx

# Move into the app directory
cd shopping-list

# Create the app component
spin add -t static-fileserver app
HTTP path: /...
Directory containing the files to serve: assets

# Create the frontend-asset folder
mkdir assets

# Create the API component
spin add -t http-rust api
Description: Shopping list API
HTTP path: /api/...
Enter fullscreen mode Exit fullscreen mode

Having our Spin app bootstrapped and added all necessary components, we can move on and take care of the database.

Preparing the database

Although Spin takes care of running the database behind the scenes, we must ensure that the desired component(s) can interact with the database. This is necessary because Wasm is secure by default and Wasm modules must explicitly request permissions to use or interact with different resources/capabilities.

This might sound like a complicated task in the first place, but Spin makes this super easy. All we have to do is update the application manifest (spin.toml) and add the sqlite_databases configuration property to the desired component(s). In our case, the api component is the only one, that should be able to interact with the database. That said, update the [component.api] section in spin.toml to look like this:

[component.api]
source = "api/target/wasm32-wasi/release/api.wasm"
allowed_outbound_hosts = []
sqlite_databases = ["default"]
Enter fullscreen mode Exit fullscreen mode

Next, we have to lay out our database using a simple DDL script. We create a new file called migration.sql in the root folder of our application and add the following content to it:

CREATE TABLE IF NOT EXISTS ITEMS (
  ID INTEGER PRIMARY KEY AUTOINCREMENT,
  VALUE TEXT NOT NULL
)
Enter fullscreen mode Exit fullscreen mode

With the update to spin.toml and our custom migration.sql file, we have prepared everything regarding the database. We could move on and take care of the serverless. backend.

Implementing the serverless backend

Our serverless backend exposes three different endpoints that we’ll use later from within the frontend:

  • HTTP GET at /api/items : to retrieve all items of the shopping list
  • HTTP POST at /api/items: to add a new item to the shopping list by providing a proper JSON payload as the request body
  • HTTP DELETE at /api/items/:id: to delete an existing item from the shopping list using its identifier

The Spin SDK for Rust comes with batteries included to create full-fledged HTTP APIs. We use the Router structure to layout our API and handle incoming requests as part of the function decorated with the #[http_component] macro:

use spin_sdk::http_component;
use spin_sdk::http::{IntoResponse, Request, Router};

#[http_component]
fn handle_api(req: Request) -> anyhow::Result<impl IntoResponse> {
  let mut r = Router::default();
  r.post("/api/items", add_new);
  r.get("/api/items", get_all);
  r.delete("/api/items/:id", delete_one);
  Ok(r.handle(req))
}
Enter fullscreen mode Exit fullscreen mode

Before we dive into implementing the handlers, we add some 3rd party crates to simplify working with JSON and creating HTML fragments:

# Move into the API folder
cd api

# Add serde and serde_json
cargo add serde -F derive
cargo add serde_json

# Add build_html
cargo add build_html
Enter fullscreen mode Exit fullscreen mode

Those commands will download the 3rd party dependencies and add them to the Cargo.toml file.

Implementing the POST endpoint

To add a new item to the shopping list, we want to issue POST requests from the frontend to the API and send the new item as JSON payload using the following structure:

{ "value": "Buy milk"}
Enter fullscreen mode Exit fullscreen mode

First, we create the corresponding API model as a simple struct and decorate it with Deserialize from the serde crate:

use serde::{Deserialize};

#[derive(Deserialize)]
pub struct Item {
  pub value: String,
}
Enter fullscreen mode Exit fullscreen mode

Having our model in place, we can implement the add_new handler. We can offload the act of actually deserializing the payload to the http crate, by precisely specifying the request type (here http::Request<Json<Item>>). Additionally, we use Connection and Value from spin_sdk::sqlite to securely construct the TSQL command for storing the new item in the database. Finally, we return an HTTP 200 along with a HX-Trigger header.

Later, when implementing the frontend, we’ll use the value of the HX-Trigger header, when implementing the frontend:

use spin_sdk::sqlite::{Connection, Value};
use spin_sdk::http::{Json, Params};

fn add_new(req: http::Request<Json<Item>>, _params: Params) -> anyhow::Result<impl IntoResponse> {
  let item = req.into_body().0;
  let connection = Connection::open_default()?;
  let parameters = &[Value::Text(item.value)];
  connection.execute("INSERT INTO ITEMS (VALUE) VALUES (?)", parameters)?;
  Ok(Response::builder()
    .status(200)
    .header("HX-Trigger", "newItem")
    .body(())?)
}
Enter fullscreen mode Exit fullscreen mode

Implementing the GET endpoint

Next on our list is implementing the handler to retrieve all shopping list items from the database. Before we dive into the implementation of the handler, let’s revisit our Item struct and extend it, to match the following:

#[derive(Debug, Deserialize, Serialize)]
struct Item {
  #[serde(skip_deserializing)]
  id: i64,
  value: String,
}
Enter fullscreen mode Exit fullscreen mode

Instead of returning plain values as JSON, our API will create response bodies as HTML fragments (Content-Type: text/html) with htmx enhancements.

To achieve this, we must specify how a single item (an instance of the Item structure) is represented as HTML. The build_html create provides the Html trait, which we can use to lay out how an item should look like in HTML. Let’s implement the Html trait for our custom type
Item as shown here:

use build_html::{Container, ContainerType, Html, HtmlContainer};

impl Html for Item {
  fn to_html_string(&self) -> String {
    Container::new(ContainerType::Div)
      .with_attributes(vec![
        ("class", "item"),
        ("id", format!("item-{}", &self.id).as_str()),
      ])
      .with_container(
        Container::new(ContainerType::Div)
          .with_attributes(vec![("class", "value")])
          .with_raw(&self.value),
      )
      .with_container(
        Container::new(ContainerType::Div)
          .with_attributes(vec![
            ("class", "delete-item"),
            ("hx-delete", format!("/api/items/{}", &self.id).as_str()),
          ])
          .with_raw("❌"),
      )
      .to_html_string()
  }}
Enter fullscreen mode Exit fullscreen mode

On top of creating an HTML representation of the actual item, the snippet also contains a hx-delete attribute. We add this attribute to the delete-icon, to allow users to delete a particular item directly from the shopping list.

The handler implementation (get_all) reads all items from the database, constructs a new instance of Item for every record retrieved, and transforms every instance into an HTML string by invoking to_html_string. Finally, we join all HTML representations together into a bigger HTML fragment and construct a proper HTTP response.

fn get_all(_r: Request, _p: Params) -> anyhow::Result<impl IntoResponse> {
  let connection = Connection::open_default()?;
  let row_set = connection.execute("SELECT ID, VALUE FROM ITEMS ORDER BY ID DESC", &[])?;

  let items = row_set
    .rows()
    .map(|row| Item {
      id: row.get::<i64>("ID").unwrap(),
      value: row.get::<&str>("VALUE").unwrap().to_owned(),
    })
    .map(|item| item.to_html_string())
    .reduce(|acc, e| format!("{} {}", acc, e))
    .unwrap_or(String::from(""));

  Ok(Response::builder()
    .status(200)
    .header("Content-Type", "text/html")
    .body(items)?)
}
Enter fullscreen mode Exit fullscreen mode

Implementing the DELETE endpoint

The last endpoint we have to implement is responsible for deleting an item based on its identifier. We do some housekeeping here to ensure that requests contain an identifier and ensure that the identifier provided is a valid i64. If that’s the case, we delete the corresponding record from the database and return an empty response with status code 200 (which is done via Response::default()):

fn delete_one(_req: Request, params: Params) -> anyhow::Result<impl IntoResponse> {
  let Some(id) = params.get("id") else {
    return Ok(Response::builder().status(404).body("Missing identifier")?);
  };
  let Ok(id) = id.parse::<i64>() else {
    return Ok(Response::builder()
      .status(400)
      .body("Unexpected identifier format")?);
  };
  let connection = Connection::open_default()?;
  let parameters = &[Value::Integer(id)];

  match connection.execute("DELETE FROM ITEMS WHERE ID = ?", parameters) {
    Ok(_) => Ok(Response::default()),
    Err(e) => {
      println!("Error while deleting item: {}", e);
      Ok(Response::builder()
        .status(500)
        .body("Error while deleting item")?)
    }
  }}
Enter fullscreen mode Exit fullscreen mode

With that, our serverless backend is done and we can move on and take care of the frontend.

Implementing the Frontend

We’ll keep the frontend as simple as possible. All sources will remain in the assets folder (you remember, we specified that as the source for the static-fileserver component when creating the Spin app at the very beginning).

First, let’s create all necessary files and bring in our 3rd party dependencies (htmx and the json-enc extension):

# Move into the assets folder
cd assets

# Create the HTML file
touch index.html

# Download the pre-coded CSS file
curl -o style.css \
  https://raw.githubusercontent.com/ThorstenHans/spin-htmx/main/app/style.css

# Download the latest version of htmx
curl -o htmx.min.js \
  https://unpkg.com/browse/htmx.org@1.9.10/dist/htmx.min.js

# Download the latest version of json-enc
curl -o json-enc.js \
  https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js
Enter fullscreen mode Exit fullscreen mode

As a starting point, we use a fairly simple HTML page. Please note that the page is already linking the stylesheet (<link rel=...>) as part of the <head> tag.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Shopping List</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <header>
    <h1>Shopping List | <small>powered by Spin &amp; htmx</small></h1>
    <p class="slug">This is a simple shopping list for demonstrating the combination of
      <a href="https://htmx.org/" target="_blank">htmx</a> and
      <a href="https://developer.fermyon.com" target="_blank">Fermyon Spin</a>.
    </p>
  </header>
  <main>
    <div id="all-items">
    </div>
  </main>
  <aside>
    <h2>Add a new item to the shopping list</h2>
    <form>
      <input type="text" name="value" placeholder="Add Item" maxlength="36">
      <button type="submit">Add</button>
    </form>
  </aside>
  <footer>
    <span>Made with 💜 by</span>
      <a href="https://www.fermyon.com" target="_blank">Fermyon</a>
      <span>Find the code on</span>
      <a href="xxx" target="_blank">GitHub</a>
  </footer>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Adding htmx to the app

First, we have to ensure htmx and the json-enc extension are brought into context correctly. Update the index.html file and add the following <script> tags before the closing </body> tag:

<!-- ... -->
    <script src="/htmx.min.js"></script>
    <script src="json-enc.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Loading and displaying items

Let’s have a look at loading and displaying the items on our shopping list now!

We can use the hx-get attribute to issue an HTTP GET request to /api/items for loading all items. Using the hx-target attribute, we specify where the received HTML fragment should be placed. We control how the HTML fragment is treated with the hx-swap attribute.

Last, but not least, we use the hx-trigger attribute to determine when HMTX should issue the HTTP GET request. We update the HTML, and modify the <main> node to match the following:

<main hx-get="/api/items"
      hx-target="#all-items"
      hx-swap="innerHtml"
      hx-trigger="load">
    <!-- ... -->
</main>
Enter fullscreen mode Exit fullscreen mode

Deleting an item

The HTML fragment for every item contains an icon and a hx-delete attribute. Users can press the delete icon, to delete the corresponding item. Although we could issue the HTTP DELETE request immediately, we can prevent users from accidentally deleting items by adding a confirmation dialog. (Although there are fancier UI representations possible, we’ll keep it simple here and use the standard confirmation dialog provided by the browser.)

Because we place all items inside of #all-items, we can add hx- attributes on that particular container, to control the actual delete behavior. We customize the message shown in the confirmation dialog, using the hx-confirm attribute. To specify which child node should be manipulated when the delete operation is confirmed, we use the hx-target attribute. Again, we use hx-swap to specify that we want to remove the corresponding .item .

Let’s update the <div id="all-items"> node as shown in the following snippet:

<div id="all-items"
     hx-confirm="Do you really want to delete this item?"
     hx-target="closest .item"
     hx-swap="outerHTML swap:1s">
</div>
Enter fullscreen mode Exit fullscreen mode

Adding a new item

Our frontend contains a simple <form> which allows users to add new items to the shopping list. Upon submitting the form, we want to issue an HTTP POST request to /api/items and send the provided text as JSON to our serverless backend. Once an item has been added, we want our UI to refresh and display the updated shopping list.

Our serverless backend expects to receive the payload for adding new items as JSON. Because of this, we have added the JSON extension for htmx (json-enc.js) to our frontend project.

We can alter the default behavior of htmx (because it normally sends the data from HTML forms as form-data) by adding the hx-ext="json-enc" attribute to the <form> node.

Additionally, we will update the <form> definition by adding the hx-post and hx-swap attributes. We specify the hx-on::after-request attribute, to control what should happen after the request has been processed. Modify the <form> node to look like shown here:

<form hx-post="/api/items"
     hx-ext="json-enc"
     hx-swap="none"
     hx-on::after-request="this.reset()">
    <!-- ... -->
</form>
Enter fullscreen mode Exit fullscreen mode

By adding those four attributes to the <form> tag, we control how and where the form data is sent. On top of that, the form is reset after the request has been processed. Finally, we have to
reload the shopping list once the request has finished.

Do you remember the response that we sent from the backend when a new item has been added to the database? As part of the response, we sent an HTTP header called HX-Trigger with the value newItem. We use this value and instruct htmx to reload the list of all items. All we have to do in the frontend is alter the hx-trigger attribute on <main> and add our “custom event” as a trigger. Update the <main> tag to match the following:

<main hx-get="/api/items"
     hx-trigger="load, newItem from:body"
     hx-target="#all-items"
     hx-swap="innerHTML">
    <!-- ... -->
</main>
Enter fullscreen mode Exit fullscreen mode

Running the application locally

Now that we have implemented everything, we can test our app. To do so, we move into the project folder (the folder containing the spin.toml file) and use the spin CLI for compiling and running both components (frontend and backend).

# Build the application
# Actually, only the backend has to be compiled... 
# Spin is smart enough to recognize that
spin build

Building component api with `cargo build --target wasm32-wasi --release`
Working directory: "./api"
# ...
# ...

# Start the app on your local machine
# by specifying the --sqlite option, we tell Spin to execute the migration.sql 
# upon starting
spin up --sqlite @migration.sql

Logging component stdio to ".spin/logs/"
Storing default SQLite data to ".spin/sqlite_db.db"
Serving http://127.0.0.1:3000
Available Routes:
 api: http://127.0.0.1:3000/api (wildcard)
 app: http://127.0.0.1:3000 (wildcard)
Enter fullscreen mode Exit fullscreen mode

As the output of spin up outlines, we can access the shopping list by browsing to http://127.0.0.1:3000, which should display the serverless shopping list like this:

Shopping List powered by Spin & htmx

Deploying to Fermyon Cloud

Having our shopping list tested locally, we can take it one step further and deploy it to Fermyon Cloud. To deploy our app using the spin CLI, we must log in with Fermyon Cloud. We can login,
by following the instructions provided by the spin cloud login command.

Deploying to Fermyon Cloud is as simple as running the spin cloud deploy command from within the root folder of our project. Because our app relies on a SQLite database, we must provide a unique name for the database inside of our Fermyon Cloud account.

# Deploy to Fermyon Cloud
spin cloud deploy

Uploading shopping-list version 0.1.0 to Fermyon Cloud...
Deploying...
App "shopping-list" accesses a database labeled "default"
Would you like to link an existing database or create a new database?: 
Create a new database and link the app to it

What would you like to name your database? 
Note: This name is used when managing your database at the account level.
The app "shopping-list" will refer to this database by the label "default".
Other apps can use different labels to refer to the same database.: shopping

Waiting for application to become ready........ 
ready

Available Routes:
 api: https://shopping-list-1jcjqxxq.fermyon.app/api (wildcard)
 app: https://shopping-list-1jcjqxxq.fermyon.app (wildcard)
Enter fullscreen mode Exit fullscreen mode

Once the initial deployment has been finished, we must ensure that our ITEMS table is created in the database that was created by Fermyon Cloud. To do so, we can use the spin cloud sqlite execute command as shown in the next snippet:

# List all SQLite databases in your Fermyon Cloud account
spin cloud sqlite list
+-------------------------------+
| App       Label      Database |
+===============================+
| shopping  default    shopping |
+-------------------------------+

# Apply migration.sql to the shopping database
spin cloud sqlite execute --database shopping @migration.sql
Enter fullscreen mode Exit fullscreen mode

Fermyon Cloud automatically assigns a unique domain to our application, we can customize the domain and even bring a custom domain using the Fermyon Cloud portal.

Conclusion

In this article, we walked through building a truly serverless application based on Spin and htmx. Although our shopping list is quite simple, it demonstrates how we can combine both to build a real app. Looking at both, Spin and htmx have at least one thing in common:

They reduce complexity!

With htmx we can build dynamic frontends without having to learn the idioms and patterns of full-blown, big Single Page Application (SPA) frameworks such as Angular. We can declare our intentions directly in HTML by adding a set of htmx attributes to HTML tags.

With Spin we can focus on solving functional requirements and choose from a wide variety of programming languages to do so. Integration with services like databases, key-value stores, message brokers, or even Large Language Models (LLMs) is amazingly smooth and
requires no Ops at all.

On top of that, the spin CLI and the language-specific SDKs provide amazing developer productivity, that is unmatched in the context of WebAssembly.

Top comments (0)