Creating Serverless SPAs with HTMX

Be warned those who tread here: This is what they call a big brain developer move in the business. I'm just having a little bit of fun here exploring what is possible; don't take this too seriously.

In this article, we will build an htmx-driven simple counter app that works without a network connection or a server being present. You can check out the running example here, and the full runnable source code here.

What is HTMX?

Remember back in the old days? Where you could just rent a webspace for like $0.19 a month, upload some PHP files, and run them? Well, those days are gone! Today, you need at least 3 package managers, a DSL on top of a linter on top of JavaScript, 2 competing and incompatible standards on how to do modules, a bundler that is actually 3 bundlers in a trenchcoat, and - of course - React.

A small group of grug-brained developers decided that this was bad, actually, and htmx was created as a result. Instead of pushing all of this complexity to the frontend, they invented a natural extension of the core Hypertext idea, creating a declarative way of doing AJAX requests from any HTML element while relying on a traditional web server to render dynamic user interfaces.

Instead of just links and forms being able to do HTTP requests, now any element can. Instead of requiring a full page refresh, now only a small part of the page can be updated. Instead of shipping 500kb min+gzip of JavaScript to the browser, the HTML stays nice and lean, while state and logic is driven primarily by the server.

The chief grug claims that by doing that, they can continue working using the old ways, where they have decades of experience on how to scale and structure web applications. They can benefit from big, established, and well-tested frameworks, instead of submitting to the JavaScript churn that just re-invents old stuff every 6 months. They can finally use real languages again on the server, without having to worry about duplicating code.

As a frontend developer, this cannot stand! For years, people have looked down on me for not "being a real programmer!" I've made my deal with the complexity demon, and now they do this? It's time to smite those heretics once and for all!

We're going to reinvent some stuff

Now, let's get serious and delve into the practical steps of building serverless SPAs using HTMX and ServiceWorker API.

The ServiceWorker API is a seriously underestimated feature of modern browsers. If people have used it at all, it's usually in the form of some PWA plugin auto-generating Workbox scripts to cache some files. But this is far from all it can do: You can intercept requests, look at the pathname, load stuff from a database, produce an HTML string, and respond with that to the "main" JavaScript context, without any actual network request taking place! Essentially, you can ship your entire server alongside your frontend to the user, and have it run locally.

So if htmx is built around the idea of having a server to drive user interaction, and service workers are basically servers, can we just build our server as a service worker, and use it with htmx? The answer might unsettle parts of the grug-brained kin!

Setting up Vite

Lets setup a new Vite project. Since we can only ship a single bundle to the browser as a service worker, we need vite to combine all of our source files and dependencies into a single file.

mkdir serverless-htmx && cd serverless-htmx
npm init -y
npm install -D vite@latest

After installing vite, add the following to your vite.config.ts:

import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        rollupOptions: {
            input: {
                main: resolve(__dirname, 'index.html'),
                sw: resolve(__dirname, 'src/index.tsx')
            output: {
                entryFileNames (chunk) {
                    if ( === 'sw') {
                        return 'sw.js'
                    } else {
                        return 'assets/[name]-[hash].js'


This configures vite to produce 2 bundles: One based on index.html, and a second one based on the file src/index.tsx. The second bundle will be outputted a single file called sw.js. This makes sure that we know the URL to the service worker we want to register.

Unfortunately, this setup does not work using the built-in dev server. I have tried some plugins that I found, like vite-plugin-native-sw or @gautemo/vite-plugin-service-worker, but none of them fully worked either. Instead, this is the simplest configuration that will make at least the build work.

Creating the index.html

Our index.html file will be special as well: Since we want to ship our own server to the client, that server should also be responsible for producing the index page. We only need an index.html at all if there is no service worker registered yet! All it needs to do in that case is register one, and then reload itself. The service worker will then immediately take over, sending the "real" index.html to the browser:

<!DOCTYPE html>
<head lang="en">
    <meta charset="UTF-8" />
    <title>Serverless HTMX</title>
        (async () => {
            try {
                await navigator.serviceWorker.register('/sw.js', {
                    scope: '/',
                    type: 'module'

                // reload to get the page from the service worker
            } catch(err) {

Adding the Service Worker

All that's left now is the service worker that will act as the server! Create a file called src/index.tsx with the following content:

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope

// makes typescript shut up
export type {}

// these 2 event listeners make sure the browser replaces the server worker
// as soon as possible if it detects an update.
self.addEventListener('install', event => {

self.addEventListener('activate', event => {

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url)
    if (url.pathname === '/') {
        const html = '<!DOCTYPE html>\n<h1>Hello, from a Service Worker!'
        event.respondWith(new Response(html, {
            headers: {
                'Content-Type': 'text/html'

    // if we don't call respondWith, do the browser default (a real network request)

And that's basically it! You should now be able to run your project by using

npx vite build && npx vite preview

After opening the page, it should automatically refresh once, and greet you with Hello, from a Service Worker. You can verify yourself using the dev tools that no network is required anymore. Try going "offline" using the network throttle feature and refresh your page! You can even stop the preview server and the page will still respond!


Of course, we now need to bring back all of the complexity that we have left behind. This is what we came for! In addition to that, we should really also add this new htmx thingy that our grug-brained friends keep talking about. More libraries should always be better, and we don't want to reinvent some stuff, right?

npm install preact preact-render-to-string @jreusch/router-node

We'll use preact and preact-render-to-string as a templating solution, and @jreusch/router-node for handling routing in our service worker.

Since @jreusch/router-node works everywhere, we can use it to handle our fetch events, doing basic routing based on the url:

import { h } from 'preact'
import { render } from 'preact-render-to-string'
import { compile, get, post } from '@jreusch/router-node'

// ... other events ...

let count = 0

const router = compile(
    // how to get method and pathname
    (ev: FetchEvent) => ev.request.method,
    (ev: FetchEvent) => new URL(ev.request.url).pathname,

    // actual routes, we just return JSX VNodes that we want to render
    get('/', (params, ev) => <Index count={count} />),
    post('/increment', (params, ev) => <Count count={++count} />),
    post('/decrement', (params, ev) => <Count count={--count} />),

self.addEventListener('fetch', (event: FetchEvent) => {
    const url = new URL(event.request.url)
    // if you request a 3rd-party page, always do the default
    if (url.hostname !== location.hostname) {

    // call the router to get the VNode, or null
    const vdom = router(event)
    // if we get null, we could not find the route, so do the browser default.
    // (this might happen when requesting js/css/fonts etc)
    if (!vdom) {

    const html = '<!DOCTYPE html>\n' + render(vdom)
    event.respondWith(new Response(html, {
        headers: {
            'Content-Type': 'text/html'

Note that using Preact might require some changes to your tsconfig.json. You can look at mine in the example project, or follow the steps in the Preact guide.

All that is left is to implement our components. I use Preact and JSX just as a templating language here. There is no client-side rendering going on, and this is also not using react-server-components or some other fancy new thing. I'm not here to reinvent simple stuff, after all!

In those components templates, we can now use our newly defined routes, hooking everything up using HTMX attributes:

function Index(props: { count: number }) {
    const title = 'HTMX without a Server!'

    return <html lang="en">
            <meta charSet="UTF-8" />
            <script src=""></script>
                <button hx-post="/decrement" hx-target="#count" hx-swap="outerHTML">

                <Count count={props.count} />

                <button hx-post="/increment" hx-target="#count" hx-swap="outerHTML">

function Count(props: { count: number }) {
    return <span id="count">
        Clicked {props.count} times

After rebuilding and reloading the page, you should now have the classic "counter" example, built with HTMX, fully running in the browser!

If you still see the old hello world page, try to unregister the old service worker using the dev tools. Getting the update to trigger reliably seems to be a little bit tricky sometimes, especially in Chrome.

You can also check out the running example here, and the full runnable source code here.