airasoul.

Building Hypermedia Applications with htmx

Cover Image for Building Hypermedia Applications with htmx

Building Hypermedia Applications with htmx

In this series of posts, we will explore hypermedia APIs and use htmx to build a simple search application that queries characters from the Start Wars universe, using this free api - swapi.dev:

Please read the first part of this series here

The source code for this post can be found here on branch phase-2, pull this gihub repo and follow along with these examples.

htmx & hypermedia

This second post focuses on htmx, a hypermedia oriented library with a focus on enhancing HTML as a hypermedia. htmx achieves this by removing some of the constraints of HTML; these constraints being:

  • only a and form tags are able to make HTTP requests
  • only click and submit events trigger requests
  • only GET & POST methods are available

htmx can do much more, the following is from their website htmx.org:

htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext

If you are new to the concept of hypermedia, I would suggest reading their amazing free book Hypermedia Systems on the subject, if you can you should purchase the book in order to support their efforts.

Lets build

Carrying on from the first post lets update our server and add a second endpoint /search, which will allow us to perform a partial update of our webpage.

The Search endpoint is slightly different to the Index endpoint which returns an entire page, this new endpoint returns a html fragment. We also add validation to the request; Elysia, has validation built into the framework, the third argument of the get function allows us to define a validation object.

We also include the static plugin which allows us to serve static content from the public folder, we us this to serve a loading image, when making requests to the search endpoint.

src/index.tsx

import { Elysia, t } from 'elysia'
import { html } from '@elysiajs/html'
import { staticPlugin } from '@elysiajs/static'
import { Search } from './api/search'
import { Index } from './api'
import { searchValidation } from './validation/search'

export const app = new Elysia()
  .use(html())
  .use(staticPlugin())
  .get("/", Index)
  .get('/search', Search, { ...searchValidation })
  .listen(3000)

export type App = typeof app

Our validation object, validates the search parameter, we have restricted the allowed values, with a min and max length.

src/validation/search.tsx

import { t } from 'elysia'

export const searchValidation = {
  query: t.Object({
    search: t.String({ minLength: 2, maxLength: 10 })
  }),
}

src/api/search.tsx

The search endpoint calls the search service, passing the query search, and renders and returns a CharacterList.

import { CharacterList } from '../components/characterList'
import { search } from '../services/starwars'

export type SearchType = {
  html: (children: JSX.Element) => JSX.Element
  query: string
}

export const Search = async ({ html, query }: SearchType) => {
  const q = query?.search?.toString() || ''
  const data = await search(q)
  return html(<CharacterList count={data?.count} results={data?.results} query={q} />)
}

src/components/layout.tsx

We have made a small change to the Layout component, to include as a script, the htmx library; https://unpkg.com/htmx.org

export type LayoutType = {
  title?: string
  children?: JSX.Element
}

export function Layout({ title, children }: Html.PropsWithChildren<LayoutType>): JSX.Element {
  return (
    <>
      {'<!doctype html>'}
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
          <script src="https://unpkg.com/htmx.org"></script>
          <script src="https://cdn.tailwindcss.com"></script>
          <title safe>{title}</title>
        </head>
        <body>
          <div class="m-8">
            {children as 'safe'}
          </div>
        </body>
      </html>
    </>
  );
}

Introducing htmx

In order to get htmx to work we add various attributes to the input tag of our Home component.

The htmx attribute hx-get will cause the input element to issue a get request to the endpoint /search

The htmx attribute hx-trigger allows us to specify what triggers the request via event modifiers. We are using keyup and changed, so a request will only fire when the element has changed, we also apply a delay so the request triggers after a 1 second delay.

The htmx attribute hx-target allows us to specify where the results of the request should be appended. We have defined this as #results the id of the div element.

The htmx attribute hx-indicator allows us to specify the class attached to an element that will be displayed for the duration of the request, we use this to display a loading image.

export type HomeType = {
  title?: string
  children?: JSX.Element
}

export function Home({ title }: Html.PropsWithChildren<{ title?: string }>) {
  return (
    <div class="m-8" >
      <h1 class="text-8xl font-bold" safe>
        {title}.
      </h1>
      <input
        class="mt-8 p-4 text-6xl font-bold text-blue-700 border-2 border-gray-100 rounded-md"
        autofocus="true"
        type="search"
        name="search"
        placeholder="Search..."
        hx-get="/search"
        hx-trigger="search, keyup changed delay:500ms"
        hx-target="#results"
        hx-indicator=".htmx-indicator"
      />
      <div id="results">
        <img width="64" class="htmx-indicator" src="/public/loading.svg"/>
      </div>
    </div>
  )
}

This completes our work on htmx searching the api will now use partial updates, the page does not reload after search.

Client-side templating with nunjucks

htmx also support client side templating, if you would prefer or in many cases, have to use JSON based data Apis. htmx supports various templating engines, below we use nunjucks.

Run the following to install serve and run this page.

npm install --global serve
npx serve -p 3000 public

public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://unpkg.com/htmx.org"></script>
    <script src="https://unpkg.com/htmx.org/dist/ext/client-side-templates.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/nunjucks@3.2.4/browser/nunjucks.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body>
    <div class="m-8" >
      <div hx-ext="client-side-templates">
        <h1 class="text-8xl font-bold">
          These are not the droids you are looking for.
        </h1>
        <input
          class="mt-8 p-4 text-6xl font-bold text-blue-700 border-2 border-gray-100 rounded-md"
          autofocus
          type="search"
          name="search"
          placeholder="Search..."
          hx-indicator=".htmx-indicator"
          hx-get="https://swapi.dev/api/people/"
          hx-trigger="search, keyup changed delay:500ms"
          hx-target="#results"
          hx-request='{"noHeaders": true}'
          nunjucks-template="sw-template"
        >
        <div hx-trigger="revealed">
          <img width="64" id="spinner" class="htmx-indicator" src="/loading.svg"/>
        </div>
        <template id="sw-template">
          <div class="mt-4">
            <h1 class="mb-4"> Found {{ count }} results.</h1>
            {% for item in results %}
            <div class="mt-4">
              <h1 class="mb-4 text-4xl font-bold">{{ item.name }}</a></h1>
              <div>Height: {{ item.height }}</a></div>
              <div>Mass {{ item.mass }}</div>
              <div>Hair Color {{ item.hair_color }}</div>
              <div>Skin Color {{ item.skin_color }}</div>
              <div>Eye Color {{ item.eye_color }}</div>
              <div>Birth Year {{ item.birth_year }}</div>
              <div>Gender {{ item.gender }}</div>
            </div>
            {% endfor %}
          </div>
        </template>
        <div id="results"></div>
      </div>
    </div>
  </body>
</html>

Wrapping up

Thats all, thanks for reading.