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
andform
tags are able to make HTTP requests - only
click
andsubmit
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.