Although my blog is built using Hugo - a famous Static Site Generator - I’ve some functionality baked into the page that requires having a backend (basically an HTTP API). Historically, the backend was hosted in Azure leveraging Azure Container Apps. Recently, I replaced the entire backend for my blog using a Fermyon Spin application hosted in the fully managed Fermyon Cloud. In this article, I explain how I migrated away from containers towards server-side WebAssembly with Fermyon Spin 💫.

Before we dive into the story of replacing my backend with Fermyon Spin, let’s do a quick refresher on what Static Site Generators (SSGs) are and why they’re a good candidate for combining with server-side WebAssembly.

What is a Static Site Generator (SSG)

Static Site Generators (SSGs) are a popular alternative to traditional Content Management Systems (CMS) for building websites. They provide a simple and efficient way to create and manage web content by generating static files that can be hosted on a web server or a CDN.

SSGs offer flexibility and customization through themes, plugins, and templates, allowing us as developers to create unique designs and functionalities to websites. Additionally, SSGs integrate easily with version control systems like Git and can be deployed with ease to platforms like Netlify, Azure Static Web Apps, or GitHub Pages. These days, some of the most popular SSGs are Next.JS, Hugo, Gatsby, and Jekyll (You can find a quite comprehensive list of SSGs here on jamstack.org).

Bottom line, SSGs allow you to run a website or blog almost everywhere and remove the burden of managing many infrastructure components.

The Requirements

I keep my blog simple, clean, and fast. That said, only a few features actually need a backend API.

My blog comes with a built-in search experience powered by Azure Cognitive Search, which allows visitors to find the content they’re interested in quickly. Although Azure Cognitive Search provides an HTTP API, the callee must present a sensitive API token which I want to hide behind a simple proxy API.

Readers and potential sponsors can reach out directly to me using the dedicated pages on my blog. Obviously, those requests must “somehow” find their way to me. Two HTTP POST endpoints should address this.

Last but not least, readers of my blog can subscribe to my newsletter to get a short notification delivered to their inbox every time I publish a new article. An HTTP Post endpoint is required to add someone to the newsletter.

That said, the entire backend of my blog needs the following endpoints:

  • POST /subscriptions: Store new newsletter subscribers
  • POST /contact: Store contact request
  • POST /sponsor: Store sponsor request
  • GET /search: Proxy search requests to Azure Cognitive Search

Besides that, the backend should respond to potential Cross-Origin Resource Sharing (CORS) preflight requests.

Implementing the Backend with Fermyon Spin

With the latest release of Fermyon Spin (1.1) the team extended the Rust SDK and added the Router, which makes building APIs like the one described above using a single Wasm-Component approach a lot easier and cleaner than before.

I created a new Spin application using the super handy spin CLI:

# Create the new spin app
spin new --accept-defaults http-rust blog-backend

# Open blog-backend in an editor (Helix here)
hx blog-backend

I won’t post the entire code of my backend here. Instead, I’ll outline the most interesting points and provide some hints that may be useful in the long run. The actual component implementation is super simple; it uses the new Router to layout the API and let it handle the actual request:

#[http_component]
fn handle_blog_apis_rust(req: Request) -> Result<Response> {
  let mut router = spin_sdk::http::Router::default();
  router.post("/contact", requests::process_contact_request);
  router.post("/sponsor", requests::process_sponsor_request);
  router.post("/subscriptions", newsletter::subscribe);
  router.get("/search", search::search);
  router.add("/...", http::Method::OPTIONS, cors::process_preflight);
  router.handle(req)
}

Let’s look at the handler for integrating the Azure Cognitive Search here for demonstration purposes. I placed the handler in a dedicated module called search. The actual handler function looks like this:

pub(crate) fn search(req: Request, _params: Params) -> Result<Response>{
  println!("[SEARCH]: Endpoint invoked");
  let Some(search_term) = get_query_string_parameter(req.uri(), "query") else {
    println!("[Error]: Could not find search term");
    return Ok(http::Response::builder()
      .status(http::StatusCode::BAD_REQUEST)
      .body(None)?);
  };
  println!("[SEARCH]: Endpoint invoked with '{}'", &search_term);

  let Ok(search_url) = config::get(CFG_NAME_SEARCH_URL) else {
    println!("[Error]: Could not find Azure Search Service URL");
    return Ok(http::Response::builder()
      .status(http::StatusCode::INTERNAL_SERVER_ERROR)
      .body(None)?);
  };

  let Ok(search_api_key) = config::get(CFG_NAME_SEARCH_API_TOKEN) else {
    println!("[Error]: Could not find Azure Search API Key");
    return Ok(http::Response::builder()
      .status(http::StatusCode::INTERNAL_SERVER_ERROR)
      .body(None)?);
  };

  let query_string = build_azure_search_query_string(search_term.as_str());
   
   
  let req = http::Request::builder()
    .method(http::Method::GET)
    .header(http::header::ACCEPT, "application/json")
    .header(http::header::CONTENT_TYPE, "application/json")
    .header("api-key", search_api_key)
    .uri(format!("{}{}", search_url, query_string))
    .body(None)?;

  match spin_sdk::outbound_http::send_request(req) {
    Ok(r) => {
      println!("[SEARCH]: Will respond with search results");
      Ok(builder_with_cors(r.status())
      .body(r.body().clone())?)
    },
    Err(e) => {
      println!("[Error]: Did not receive successful response from Azure Search Service ({})", e);
      Ok(http::Response::builder()
      .status(http::StatusCode::INTERNAL_SERVER_ERROR)
      .body(None)?)
    },
  }
}

Don’t feel overwhelmed by the code. It’s actually pretty simple: We grab the search term, which must be provided as a query string parameter. Essential configuration data is loaded. Having all necessary data in place, it constructs a new HTTP request using http::Request::builder() and utilizes outbound HTTP functionality Spin provided to send the HTTP request to Azure Cognitive Search.

We pass the original response from Azure Cognitive Search to the callee. We must load configuration data from somewhere. In my case, I want to run the app in Fermyon cloud, meaning that all configuration data is located in Spin.toml. If you haven’t worked with configuration data in Spin, consider reading my article “Master configuration data in Fermyon Spin” to dive deeper.

Chances are good that you stumbled upon (at least one of) those println macros. Everything you write to STDOUT will be collected by Fermyon Platform / Fermyon Cloud at runtime. It is a super simple way to get additional insights about how your API is used once you’ve deployed it (besides traditional metrics gathered by the platform).

Last but not least, let’s have a quick look at the handler responsible for responding to any CORS preflight request:

use anyhow::Result;
use http::response::Builder;
use spin_sdk::{http::{Params, Request, Response}, config};
use crate::{
    constants::{CFG_NAME_CORS_ORIGIN, CFG_NAME_CORS_METHODS}
};

fn builder_with_cors(origin: String, methods: String, status: http::StatusCode) -> Builder {
    http::Response::builder()
        .status(status)
        .header(
            "Access-Control-Allow-Origin",
            origin,
        )
        .header("Access-Control-Allow-Methods", methods)
        .header("Access-Control-Allow-Headers", "*")
}

pub(crate) fn process_preflight(_req: Request, _params: Params) -> Result<Response> {
    let Ok(origin) = config::get(CFG_NAME_CORS_ORIGIN) else {
        println!("[ERROR]: Could not find CORS origin");
        return Ok(http::Response::builder()
            .status(http::StatusCode::INTERNAL_SERVER_ERROR)
            .body(None)?);
    };

    let Ok(methods) = config::get(CFG_NAME_CORS_METHODS) else {
        println!("[ERROR]: Could not find CORS methods");
        return Ok(http::Response::builder()
            .status(http::StatusCode::INTERNAL_SERVER_ERROR)
            .body(None)?);
    };

    Ok(builder_with_cors(origin, methods, http::StatusCode::OK).body(None)?)
}

Again the handler loads necessary data using the configuration API provided by Spin SDK for Rust, if the configuration data is present and loaded successfully, it responds to the preflight request with an HTTP 200, containing necessary HTTP response headers.

Hosting on Fermyon Cloud

Besides Spin, Fermyon offers Fermyon Cloud. A fully managed cloud for running WebAssembly workloads built with Spin. Every registered user can deploy up to five (5) Spin applications for free to Fermyon Cloud. For me, this was exactly what I was looking for. A fully managed environment where I can get my Spin app executed. Being able to run on the free tier is a nice gimmick 💝 that I really appreciate (🤩 thanks, Fermyon).

Deploying to Fermyon Cloud is super straight. There are only two requirements.

  1. We must authenticate and authorize spin CLI
  2. We must have built our Spin application locally at least once

These are super simple requirements that we can achieve with ease.

Once we’ve registered for Fermyon Cloud, we authenticate and authorize the local spin CLI using the spin login command. Every Spin application can be built using the spin build command. Finally, we can deploy to Fermyon Cloud using the spin deploy command. This will package the Spin app as bindle and deploy it to Fermyon Cloud.

In the Fermyon Cloud portal, you can configure a custom subdomain for your Spin application. 🏃🏻‍♂️ Hurry up, famous once may be taken sooner than later 😁.

Fermyon Cloud Portal

Fermyon Cloud Portal

Continuous Deployment with GitHub Actions

Fermyon also provides a set of actions that we can use to implement continuous deployment (CD). To authenticate with Fermyon Cloud from within a GitHub Action runner, we can use a so-called Personal Access Token (PAT). We can generate PATs directly in the Fermyon Cloud portal.

Fermyon Cloud Portal - User Settings

Fermyon Cloud Porta - User Settings

Having the PAT copied to the clipboard, we could store it as a repository secret on GitHub using the GitHub CLI gh (Obviously, you can also do that using the GitHub website):

# Set Fermyon PAT as repository secret
gh secret set FERMYON_CLOUD_PAT
> paste Fermyon Cloud PAT here...

At this point we can create the new GitHub action and use the actions provided Fermyon to install spin CLI, build the app and deploy it to Fermyon Cloud:

name: CI / CD
on:
  push:
    branches:
      - main
jobs:
  spin:
    runs-on: ubuntu-latest
    name: Build and deploy
    steps:
      - uses: actions/checkout@v3
      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.66
          targets: wasm32-wasi
      - name: Install Fermyon Spin
        uses: fermyon/actions/spin/setup@v1
      - name: Build and Deploy to Fermyon Cloud
        uses: fermyon/actions/spin/deploy@v1
        with:
          fermyon_token: ${{ secrets.FERMYON_CLOUD_PAT }}

Having this GitHub Action in place, we could simply push new APIs or modifications to GitHub, which will invoke the GitHub Action automatically and deploy a new increment to Fermyon Cloud.

Integration with the SSG

Every SSG can produce HTML, CSS, and JavaScript. That said, we can use regular client-side JavaScript to interact with the backend running in Fermyon Cloud. Again let’s quickly look at the search capability for demonstration purposes.

Although I do quite some fancy rendering stuff in JavaScript for presenting search results, the essential part is constructing the HTTP request and sending it to the backend using the fetch API:

const searchForm = document.querySelector('#search-form');
const queryInput = document.querySelector('#query');
const searchButton = document.querySelector('#do-search');

searchForm.addEventListener('submit', (e) => {
    const query = queryInput.value;
    e.preventDefault();
    if (query.length >= 2) {
      clearResults();
      searchButton.value = 'Searching ...';
      searchButton.disabled = true;
      fetch(`https://thns.fermyon.app/search?query=${encodeURIComponent(query)}`)
        .then(response => response.json())
        .then(data => {
          setResultsCount(data.value.length);
          data.value.forEach(r => displaySearchResult(r));
          searchButton.value = 'Search';
          searchButton.disabled = false;
        })
        .catch(err => {
          searchButton.value = 'Search';
          searchButton.disabled = false;
        });
    }
  }, false);

In Hugo, I decided to use a dedicated layout for the search page to load the JavaScript shown above only on that particular page. I use a simple form with a submit button to trigger the search, as shown here:

{{ partial "header.html" . }}

<div class="container">
  <article class="post-container">
    {{ partial "page-header.html" . }}
  <form id="search-form">
    <div class="form-group">
    <input id="query" type="text" placeholder="What are you looking for?" /> <input id="do-search" class="button"
      type="submit" value="Search" />
    </div>
    <p class="count"></p>
  </form>
  <ol class="post-list"></ol>
  </article>
</div>
<script defer="true" src="/js/search.js"></script>

{{ partial "footer.html" . }}

Further Reading

Caelb Schoepp from Fermyon wrote a super cool article and explained how he built a like button for his blog (which also runs on Hugo). You can read his article “How I Built a Like Button for My Blog with Spin” on the Fermyon blog.

Conclusion

If you’re looking for a faster, more cost-effective, and easier way to manage your backend, consider using serverless functions running on WebAssembly. Spin and Fermyon Cloud make running a fairly simple backend at no cost (I can imagine this could change at some point in time) in a fully managed environment.

While the switch may seem daunting at first, the benefits are well worth it. I’m thrilled with my decision to make the switch, and I think you will be too. 💫