HTMX with Bun: A Real World App
sjdonado
Posted on March 5, 2024
Let's build a real-world app with HTMX + Tailwind CSS + Bun. If you are already familiar with these tools, feel free to skip the context part.
Context
HTMX, HTMZ, HTMY...
All we need to achieve decent smooth reactivity is already out there, built-in by the major browsers. As an example, here is one interesting front-end framework:
<iframe hidden name=htmz onload="setTimeout(()=>document.querySelector(contentWindow.location.hash||null)?.replaceWith(...contentDocument.body.childNodes))"></iframe>
That's it, just an iframe, try it out yourself on https://leanrada.com/htmz/.
The philosophy behind this is the same as HTMX and the same one that drives this post: Don't reinvent rendering, just use HTML.
The blazingly fast JS server
With HTMX in mind, the next step is to get up and running a a simple web server.
Bun has gained a lot of popularity in the last few months. It is fast, and there is a framework called ElysiaJS that resembles Express.js and promises to be 22 times faster than Express.
Additionally, Bun understands TypeScript and JSX out of the box, so we can structure our app with components, server-side render them, and rely on HTMX for client-side actions.
Tailwind CSS
Unlike traditional CSS frameworks that come with predefined components, Tailwind CSS emphasizes atomic classes that represent individual CSS properties. The bundle size could be lighter in comparison to other tools, but the strength here, IMHO, is rapid prototyping.
A Real World App
The problem: I'm an Apple Music user. Someone shares a Spotify link with me, and I don't have a way to know what's inside or listen to it.
The solution: I Don't Have Spotify scrapes the links (songs/albums/artists etc) from all your favorite streaming services, and it even provides a listening preview if you just want a quick look.
The approach
- Given a Spotify link, we need to extract its metadata: Fetch request + HTML parsing.
- Given the metadata, we need to search for the resource on each supported streaming service: Multiple API/HTML requests + parsing (adapters).
- Given the results of (1) and (2), we need to render and return the data to the client.
- Additionally, we list a JSON endpoint for a cool Raycast extension.
There are two major design decisions:
- The folder structure / separation of concerns
- Tooling + build process, how to live reload the JSX components?
For the first one, since we have multiple streaming services, we'll inevitably encounter shared logic between them. Drawing inspiration from the adapter pattern but without the boilerplate, I organized the adapters folder with pure functions. These functions all receive the same arguments: query: string
and metadata: SpotifyMetadata
, and they return a SpotifyContentLink
.
The adapters are called by the spotifySearch
function in the search service, which is also responsible for caching and updating the statistics. Why caching? In short: these calls are quite time-consuming, and we can easily hit a rate limit if we send many of them at the same time.
For the second aspect, running Bun is very straightforward: bun run --watch www/bin.ts
. However, as a real-world app, we need some JavaScript on the client side, apart from sending AJAX requests. There is an audio player that has to be rendered on the client side (in order to append listeners to the DOM). Additionally, we would like to access the Clipboard API to improve the user experience when searching.
To bundle and minify the required JavaScript, I used Vite with the rollup-plugin-copy
plugin:
import path from 'path';
import { defineConfig } from 'vite';
import copy from 'rollup-plugin-copy';
export default defineConfig({
plugins: [
copy({
targets: [
{
src: 'dist/*',
dest: 'public',
copyOnce: false,
},
],
hook: 'writeBundle',
}),
],
resolve: {
alias: {
'~/config/constants': path.resolve(__dirname, './src/config/constants.ts'),
},
},
build: {
outDir: './dist',
target: 'esnext',
rollupOptions: {
input: {
'assets/js/audio-preview': './src/views/js/audio-preview.js',
'assets/js/search-bar': './src/views/js/search-bar.js',
'assets/css/index': './src/views/css/index.css',
},
output: {
entryFileNames: '[name].min.js',
chunkFileNames: `[name].min.js`,
assetFileNames: `[name].min.[ext]`,
},
},
},
});
Showcase
- The search bar component (where HTMX shines)
export default function SearchBar() {
return (
<>
<form
id="search-form"
hx-post="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-indicator="#loading-indicator"
hx-request='\"timeout\":24000'
class="flex w-full max-w-3xl items-center justify-center"
>
<label for="song-link" class="sr-only">
Search
</label>
<input
type="text"
id="song-link"
name="spotifyLink"
class="flex-1 rounded-lg border bg-white p-2.5 text-sm font-normal text-black placeholder:text-gray-400 lg:text-base"
placeholder="https://open.spotify.com/track/7A8MwSsu9efJXP6xvZfRN3?si=d4f1e2eb324c43df"
pattern={SPOTIFY_LINK_REGEX.source}
/>
<button
type="submit"
class="ml-2 rounded-lg border border-green-500 bg-green-500 p-2.5 text-sm font-medium text-white focus:outline-none focus:ring-1 focus:ring-white"
>
<i class="fas fa-search p-1 text-black" />
<span class="sr-only">Search</span>
</button>
</form>
<div class="my-4">
<div id="search-results"></div>
</div>
</>
);
}
- The web server
import { Elysia } from 'elysia';
import { html } from '@elysiajs/html';
import { staticPlugin } from '@elysiajs/static';
import { logger } from './utils/logger';
import { apiRouter } from './routes/api';
import { pageRouter } from './routes/page';
export const app = new Elysia()
.use(html())
.use(
staticPlugin({
prefix: '',
headers: {
'Cache-Control': 'public, max-age=86400',
},
})
)
.on('beforeHandle', async ({ request }) => {
logger.info(
`${request.method} ${request.url} - ${request.headers.get('user-agent')}`
);
})
.use(apiRouter)
.use(pageRouter);
Testing
In our case, testing involves mocks and more mocks. They run on push changes to master, thanks to GitHub Actions:
The setup relies on bun:test
and AxiosMockAdapter
. It is separated into integration and unit tests, and the requests are injected using two helpers:
export const JSONRequest = (endpoint: string, body: object) => {
return new Request(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
};
export const formDataRequest = (endpoint: string, body: object) => {
const formData = new FormData();
Object.entries(body).forEach(([key, value]) => {
formData.append(key, value);
});
return new Request(endpoint, {
method: 'POST',
body: formData,
});
};
Incorporating e2e shouldn't be complex. An example of running the dev server with Playwright:
...
webServer: {
command: 'bun run dev',
port: 3333,
reuseExistingServer: !process.env.CI,
}
Wrapping up
- The source code is available on GitHub: https://github.com/sjdonado/idonthavespotify.
- The app is up and running thanks to Dokku: https://idonthavespotify.donado.co.
- The Raycast extension will be addressed in a future post, stay tuned: https://www.raycast.com/sjdonado/idonthavespotify.
Posted on March 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.