Building a Micro HTMX SSR Framework

Building a Micro HTMX SSR Framework

In the Movie Quotes App Tutorial tutorial from Platformatic's documentation suite, we walked through building a multi-tier application with the service layer built on Fastify backed by Platformatic DB and the frontend built on Astro.

In this tutorial we'll explore an alternative stack for running the Movie Quotes App frontend, with nothing but Fastify, Vite and HTMX.

Even though the JavaScript web server ecosystem has recently gained some noteworthy additions to choose from, such as Nitro, Hono and ElysiaJS, Fastify remains our recommendation to companies who want a fast, safe and mature web server for Node.js.

With over half a million weekly downloads on npm, Fastify is trusted by thousands of companies due to its performance-oriented design, rich API and simplicity all around.

Vite has quickly become the industry standard for bundling modern applications, with frameworks like Nuxt, SvelteKit and SolidStart all based on it. Vite uses native ESM to quickly deliver files in development mode, but still produces a bundle for production. Check out the motivation page in Vite's documentation to learn more.

HTMX has recently gained popularity due to its small footprint and radically simple approach of using HTML attributes to automatically enable several functionalities that would otherwise require writing JavaScript code. That is not to say HTMX applications don't require any JavaScript, which is not the case necessarily.

HTMX has a series of events that you can subscribe to via JavaScript when needed, but it provides a nice set of conventions where in most scenarios JavaScript isn't needed at all. For instance, you can cause the contents of a <div> to be automatically replaced with new markup rendered from a URL with just a few attributes:

<ul class="list">
</ul>
<button 
  hx-post="/render-list" 
  hx-swap="outerHTML"
  hx-target="previous .list">
  Load list
</button>

Check out the HTMX documentation to learn more.

Instead of relying on a full blown framework like Astro, by simply using Fastify, Vite and JSX via @kitajs/html, we can build a mini Server-Side Rendering framework that will render markup on the server from JSX page modules, but still be able to load any client-oriented assets loaded from these same page modules, such as CSS files. Which should be enough to rewrite the Movie Quotes App in JSX and HTMX.

But we'll get there step-by-step, first by taking a crash course in Vite, then learning how to integrate it into Fastify applications and finally moving on to the rewrite.

Starting a new Vite project

The best way to start a new Vite project is to use create-vite CLI, which can be easily called via npm or any other package manager. It has scaffolding templates for most frameworks, but we'll just use the vanilla preset:

npm create vite micro-htmx-framework-tutorial --template vanilla

After running this command micro-htmx-framework-tutorial should contain:

├── .gitignore
├── counter.js
├── index.html
├── main.js
├── style.css
├── javascript.svg
├── public/
│   └── vite.svg
└── package.json

One thing it doesn't have is a Vite configuration file, because it doesn't need one.

By default, Vite infers the application entry point from the index.html file, so when we run vite build in that directory, it's able to create a bundle. Vite will look for <script> tags with type set to module, and resolve all dependencies from there:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/main.js"></script>
</body>
</html>

Vite also packs a development server so running vite will launch it and serve that very index.html page. To start it, just run npm run dev.

In development mode, Vite will add a script to the page:

<script type="module" src="/@vite/client"></script>

This is responsible for enabling Vite's hot module replacement functionality.

When running vite build for this project, we'll see:

vite v5.1.1 building for production...
✓ 7 modules transformed.
dist/index.html                 0.45 kB │ gzip: 0.29 kB
dist/assets/index-BfibREyH.css  1.21 kB │ gzip: 0.62 kB
dist/assets/index-BLvcXRAk.js   2.59 kB │ gzip: 1.40 kB
✓ built in 155ms

And if we look at the production index.html, we'll see /main.js has become one of the bundled scripts as expected:

<script type="module" crossorigin src="/assets/index-BLvcXRAk.js"></script>

What we've just covered truly is the basis of how Vite works.

There's a whole lot more you can do with it, its plugin API and its already well established plugin ecosystem, but understanding these key aspects are just about enough to let the reader say they know Vite.

Turning it into a Fastify application

Vite's development server is convenient but it's there just for just that: convenience. You should not ever serve a live application from it, the assumption being once built your project's production bundle is served statically from a server.

However, that also implies your application is a Single-Page Application (SPA), that is, every piece of it is rendered through that main index.html file.

In this case all the routing is handled client-side via the History API or if using a framework, a library like Vue Router or React Router.

If we're a building a server-first, Multi-Page Application (MPA) as popularized again by Astro and more recently HTMX, we need to ensure we're able to serve that index.html file dynamically so it can include live server-side rendered pages.

Configuring the client

That's where @fastify/vite comes into play.

This plugin lets you run Vite's development from within your Fastify application in development mode, while also seamlessly serving your Vite application's bundle in production mode.

Not only that, it lets you expose your Vite client application to Fastify as a module, and it provides a series of configuration hooks that allow you to automate, among other things, how Fastify registers routes based on your Vite client application code.

In order to adapt the scaffolded Vite application into a Fastify application, we'll first follow @fastify/vite's convention of keeping client source files in a client/ folder. And add a vite.config.js file with a different root so that Vite knows about it too:

import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'

export default defineConfig({
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
})

All off these changes can be seen in this commit.

The client code is almost ready for @fastify/vite, there's two steps missing: one is moving the markup out of main.js and turning it into views/index.js, and the other is creating an index.js file under client/, which @fastify/vite automatically loads. You can use this module to expose anything necessary to the server, in this case the most obvious need is exposing the routes we have. We'll define routes by exporting a path constant from modules under views/.

So in client/index.js, we can use Vite's import.meta.glob() to automatically import and make them available to Fastify, as shown below. Note that @fastify/vite expects your Vite client module (client/index.js) to contain a routes array as an export, with objects containing a path property. That's why Object.values() is alo used:

export const routes = Object.values(
  import.meta.glob('/views/**/*.js', { eager: true })
)

@fastify/vite automatically traverses routes and register routes based on the metadata of each object in it, but we can also control how it does that by also providing a createRoute() hook to @fastify/vite. We'll see that how that looks next.

Configuring the server

First we need to install Fastify related dependencies:

npm install \
  fastify \
  @fastify/vite \
  @fastify/formbody \
  @fastify/one-line-logger

Now we can write server.js as follows:

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'
import FastifyFormBody from '@fastify/formbody'

const server = Fastify({
  logger: {
    transport: {
      target: '@fastify/one-line-logger'
    }
  }
})

await server.register(FastifyFormBody)
await server.register(FastifyVite,  {
  root: import.meta.url,
  createRoute ({ handler, errorHandler, route }, fastify, config) {
    fastify.route({
      url: route.path,
      method: route.method ?? 'GET',
      async handler (req, reply) {
        reply.type('text/html')
        reply.html({
          element: await route.default({ fastify, req, reply }),
        })
      },
      errorHandler,
      ...route
    })
  }
})

await server.vite.ready()
await server.listen({ port: 3000 })

Quite a bit of boilerplate here, and a lot going on under the hood. As can be seen in this commit, the markup from client/main.js moved to client/views/index.js and is now returned as a string from the default function export:

import javascriptLogo from '/javascript.svg'
import viteLogo from '/vite.svg'

export const path = '/'

export default () => `
  <div>
    <a href="https://vitejs.dev" target="_blank">
      <img src="${viteLogo}" class="logo" alt="Vite logo" />
    </a>
    <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
      <img src="${javascriptLogo}" class="logo vanilla" alt="JavaScript logo" />
    </a>
    <h1>Hello Vite!</h1>
    <div class="card">
      <button id="counter" type="button"></button>
    </div>
    <p class="read-the-docs">
      Click on the Vite logo to learn more
    </p>
  </div>
`

Also note that client/index.html now includes the <!-- element --> placeholder.

In a nutshell, @fastify/vite automatically turns your Vite application's index.html file into a templating function, exposed as reply.html().

To run the server in development mode:

node server.js --dev

The --dev flag is recognized by @fastify/vite by default, but you can customize this behavior by providing your own dev (boolean) flag in the plugin options.

Configuring build scripts

To run it in production mode, just remove the --dev flag, but that also requires you to run vite build beforehand so there's a production bundle @fastify/vite can load.

The catch here is that you need vite build both the client and server bundles.

  • The client bundle is what is made available to the client via index.html.

  • The server bundle is what gets exposed to Fastify on the server, namely client/index.js, which exports the routes array and anything else that might be needed on the server, accessible via @fastify/vite's hooks as seen before.

Here's how you need to set up your build scripts in package.json:

{
  "scripts": {
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --outDir dist/server --ssr /index.js",
  }
}

Notice that for the client build, we also tell Vite to produce a SSR manifest file. This is a JSON file mapping all client modules and their imported modules to bundle asset URLs. It's loaded automatically by @fastify/vite and made available in runtime in production mode. It'll be useful when we get to setup asset preloading.

Ensuring route client imports

There's a couple of problems though: in this iteration, index.html still has main.js linked, which means even though it can render other route modules, it'll still be loading the same client JavaScript file for every one of them.

There's also a minor problem with the viteLogo image import, which Vite's development server automatically serves from the public/ directory, but would need a separate @fastify/static plugin instance which is not provided out of the box. For simplicity, we'll just move vite.svg out of the public/ directory.

In order to ensure each rendered route also has its accompanying modules loaded on the client, we could try and make sure each route module is also loaded on the client. The problem with this approach is that we might have server-side imports on these modules, and even if we don't, we'll be needlessly loading the functions used for SSR on the client.

We can use a library like mlly to dynamically infer the imported files from each route module and ensure the client loads only the files that make sense, such as CSS and SVG files and client-only JavaScript files. The first step is to ensure the full module path for each route module in our routes export:

const routeHash = import.meta.glob('/views/**/*.js', { eager: true })

for (const [path, route] of Object.entries(routeHash)) {
  routeHash[path] = { ...route }
  routeHash[path].modulePath = path
}

export const routes = Object.values(routeHash)

Next we'll patch index.html to include an inlined clientImports array:

<script>
window[Symbol.for('clientImports')] = JSON.parse('<!-- clientImports -->')
</script>

And use @fastify/vite's prepareClient() hook to populate them for each route:

async function prepareClient (clientModule, scope, config) {
  if (!clientModule) {
    return null
  }
  const { routes } = clientModule
  for (const route of routes) {
    route.clientImports = await findClientImports(route.modulePath)
  }
  return Object.assign({}, clientModule, { routes })
}

async function findClientImports (path, imports = []) {
  const source = await readFile(join(root, path), 'utf8')
  const specifiers = findStaticImports(source)
    .filter(({ specifier }) => {
      return specifier.endsWith('.css') 
        || specifier.endsWith('.svg')
        || specifier.endsWith('.client.js')
    })
    .map(({ specifier }) => specifier)
  for (const specifier of specifiers) {
    const resolved = resolve(dirname(path), specifier)
    imports.push(resolved)
    if (specifier.endsWith('.client.js')) {
      imports.push(...await findClientImports(resolved))
    }
  }
  return imports
}

Note that we also allow for files ending with .client.js to be loaded on the client. This is so we can safely import it in our route module that will run on the server, and have it seamlessly loaded on the client. For that to work properly, we need to include a Vite plugin in our vite.config.js file to remove these modules altogether when loaded during SSR:

export default defineConfig({
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
  plugins: [
    ensureClientOnlyScripts()
  ]
})

function ensureClientOnlyScripts () {
  let config
  return {
    name: 'vite-plugin-fastify-vite-htmx',
    configResolved (resolvedConfig) {
      // Store the resolved config
      config = resolvedConfig
    },
    load (id, options) {
      if (options?.ssr && id.endsWith('.client.js')) {
        return {
          code: '',
          map: null,
        }
      }
    },
  }
}

Then for views/index.js, we can also have views/index.client.js:

import '/style.css'
import { setupCounter } from '/counter.js'

setupCounter(document.querySelector('#counter'))

Next we need to patch up createRoute() to include clientImports, and to ignore modules that don't have the path export, like index.client.js:

function createRoute ({ handler, errorHandler, route }, fastify, config) {
  if (!route.path) {
    return
  }
  fastify.route({
    url: route.path,
    method: route.method ?? 'GET',
    async handler (req, reply) {
      reply.type('text/html')
      reply.html({
        element: await route.default(req, reply),
        clientImports: JSON.stringify(route.clientImports),
      })
    },
    errorHandler,
    ...route
  })
}

Finally, we patch main.js to become a kind of lazy module loading hub. Thanks to import.meta.glob() we're able to ensure modules are linked in the build, but still lazy-loaded in runtime. We can then traverse the list of client imports (serialized and parsed from the inlined JSON string) that should run for the route and load them on-demand:

const allClientImports = { 
  ...import.meta.glob('/**/*.svg'),
  ...import.meta.glob('/**/*.css'),
  ...import.meta.glob('/**/*.client.js')
}

const clientImports = window[Symbol.for('clientImports')]

Promise.all(clientImports.map((clientImport) => {
  return allClientImports[clientImport]()
}))

Leveraging Vite's SSR manifest

Even though this will work, it's not ideal. At least not in all cases. This is the correct approach if lazy loading is optimal for your routes. For CSS files though, lazy loading is rarely optimal because it can cause sudden layout and styling changes.

Thanks to the SSR manifest produced by vite build, and made available as config.ssrManifest in @fastify/vite, we can compute <link> and <script> tags for imported assets at boot time.

But first let's take a moment to further refine our findClientImports() function to properly look for .css, .svg and .client.js files, and group them into separate arrays:

async function findClientImports(path, { 
  js = [], 
  css = [], 
  svg = []
} = {}) {
  const source = await readFile(join(root, path), 'utf8')
  const specifiers = findStaticImports(source)
    .filter(({ specifier }) => {
      return specifier.match(/\.((svg)|(css)|(m?js)|(tsx?)|(jsx?))$/)
    })
    .map(({ specifier }) => specifier)
  for (const specifier of specifiers) {
    const resolved = resolve(dirname(path), specifier)
    if (specifier.match(/\.svg$/)) {
      svg.push(resolved.slice(1))
    }
    if (specifier.match(/\.client\.((m?js)|(tsx?)|(jsx?))$/)) {
      js.push(resolved.slice(1))
    }
    if (specifier.match(/\.css$/)) {
      css.push(resolved.slice(1))
    }
    if (specifier.match(/\.((m?js)|(tsx?)|(jsx?))$/)) {
      const submoduleImports = await findClientImports(resolved)
      js.push(...submoduleImports.js)
      css.push(...submoduleImports.css)
      svg.push(...submoduleImports.svg)
    }
  }
  return { js, css, svg }
}

Now we can also rewrite prepareClient() to compute our preloading <head> elements either based on the actual module path, which works in development mode, or the corresponding production bundle file in production:

async function prepareClient(clientModule, scope, config) {
  if (!clientModule) {
    return null
  }
  const { routes } = clientModule
  for (const route of routes) {
    // Pregenerate prefetching <head> elements
    const { css, svg, js } = await findClientImports(route.modulePath)
    route[kPrefetch] = ''
    for (const stylesheet of css) {
      if (config.dev) {
        route[kPrefetch] += `  <link rel="stylesheet" href="/${stylesheet}">\n`
      } else if (config.ssrManifest[stylesheet]) {
        const [asset] = config.ssrManifest[stylesheet].filter((s) =>
          s.endsWith('.css'),
        )
        route[kPrefetch] +=
          `  <link rel="stylesheet" href="${asset}" crossorigin>\n`
      }
    }
    for (const image of svg) {
      // Omitted for brevity
    }
    for (const script of js) {
      // Omitted for brevity
    }
  }
  return Object.assign({}, clientModule, { routes })
}

The next step is to attach our <link> and <script> elements to our index.html file. For that we'll use a renderHead() function, which will use, by default, the head JSX or function exported by the route and main client modules, in that order.

async function renderHead(client, route, ctx) {
  let rendered = ''
  if (route[kPrefetch]) {
    rendered += route[kPrefetch]
  }
  if (route.head === 'function') {
    rendered += await route.head(ctx)
  } else if (route.head) {
    rendered += route.head
  }
  rendered += '\n'
  if (client.head === 'function') {
    rendered += await client.head(ctx)
  } else if (client.head) {
    rendered += client.head
  }
  return rendered
}

And then make sure it makes into reply.html():

function createRoute ({ 
  handler, 
  errorHandler, 
  route, 
  client
}, fastify, config) {
  if (!route.path) {
    return
  }
  fastify.route({
    url: route.path,
    method: route.method ?? 'GET',
    async handler (req, reply) {
      reply.type('text/html')
      if (route.fragment) {
        reply.send(await route.default(req, reply))
      } else {
        reply.html({
          head: await renderHead(client, route, { 
            app: fastify, 
            req, 
            reply
          }),
          element: await route.default(req, reply),
        })
      }
      return reply
    },
    errorHandler,
    ...route
  })
}

Finally, we update main.jsx to do just produce lazy imports for all our .jsx files, so even though we're never loading theses files on the client, we can sure all of its dependencies will make it to the client bundle and become available in the SSR manifest:

void {
  ...import.meta.glob('/**/*.jsx')
}

You can see all of these changes in this commit.

Adding JSX support via @kitajs/html

Now it's time to add JSX support to our app, so we can stop using raw JavaScript strings for server-side rendering our markup.

For this we'll use @kitajs/html, a tiny and fast library for generating markup from JSX. In this iteration, we'll also make the application HTMX-ready.

import inject from '@rollup/plugin-inject'

export default defineConfig({
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
  esbuild: {
    jsxFactory: 'Html.createElement',
    jsxFragment: 'Html.Fragment',
  },
  plugins: [
    inject({
       htmx: 'htmx.org',
       Html: '@kitajs/html'
    }),
    ensureClientOnlyScripts()
  ],
})

And then we can rewrite client/views/index.js as follows:

import './index.client.js'
import javascriptLogo from '/javascript.svg'
import viteLogo from '/vite.svg'

export const path = '/'

export default () => <>
  <div>
    <a href="https://vitejs.dev" target="_blank">
      <img src={viteLogo} class="logo" alt="Vite logo" />
    </a>
    <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
      <img src={javascriptLogo} class="logo vanilla" alt="JavaScript logo" />
    </a>
    <h1>Hello Vite!</h1>
    <div class="card">
      <button id="counter" type="button"></button>
    </div>
    <p class="read-the-docs">
      Click on the Vite logo to learn more
    </p>
  </div>
</>

You can see all of these changes in this commit.

Adding support for HTML fragments

At this stage, in client/counter.js, we can still see the code responsible for rendering the button's text:

export function setupCounter(element) {
  let counter = 0
  const setCounter = (count) => {
    counter = count
    element.innerHTML = `count is ${counter}`
  }
  element.addEventListener('click', () => setCounter(counter + 1))
  setCounter(0)
}

In order to demonstrate one of HTMX's abilities, we'll change rendering of that button's text to the server. Every time the user clicks, it'll fetch a new fragment from the server.

Let's begin by creating client/views/increment.jsx:

export const fragment = true
export const path = '/counter'
export const method = 'POST'

export default (req) => {
  return req.body.innerHTML.replace(/count is (\d+)/, (_, count) => {
    return `count is ${Number(count) + 1}`
  })
}

Notice that we export fragment set to true, so we can modify our createRoute() hook to take that into account and skip wrapping the fragment in the application's HTML shell (index.html). We also assume the presence of innerHTML as a form parameter, we'll make sure it's sent along with every click to the button.

Below is the update createRoute() definition:

  fastify.route({
    url: route.path,
    method: route.method ?? 'GET',
    async handler (req, reply) {
      reply.type('text/html')
      if (route.fragment) {
        reply.send(await route.default(req, reply))
      } else {
        reply.html({
          element: await route.default(req, reply),
          clientImports: JSON.stringify(route.clientImports),
        })
      }
    },
    errorHandler,
    ...route
  })

In client/views/index.client.js, we can now remove the old code based on counter.js since the updating will be server-rendered now. In fact, we can remove the file altogether and just move the CSS import to index.jsx.

To update the button's text with HTMX, first we need to add some properties to it:

<button 
  id="counter" 
  type="button"
  hx-include-inner
  hx-swap="innerHTML"
  hx-post="/counter">count is 1</button>

Note that we're using the element itself to set the initial state.

The first attribute (hx-include-inner) registers a HTMX extension to include the element's innerHTML in the request's form data.

The second (hx-swap) tells HTMX to swap the element's innerHTML with the response from the server. And the last (hx--post) tells HTMX to perform a POST request to /counter every time the button is clicked.

Now we just need to update main.js to import htmx.org and pick up on hx-include-inner, which uses the configRequest HTMX event:

import 'htmx.org'

document.addEventListener('htmx:configRequest', ({ detail }) => {
  if (detail.elt.hasAttribute('hx-include-inner')) {
    detail.parameters['innerHTML'] = detail.elt.innerHTML
  }
})

You can see all of these changes in this commit.

Reviewing our setup

It's time to take a step back and look what we've built.

├── server.js
├── vite.config.js
└── client/
    ├── views/
    │   ├── index.jsx
    │   └── increment.jsx
    ├── index.html
    ├── index.js
    └── main.js

We now have a Fastify server, running Vite as a middleware in development mode.

The setup has two key features:

  • We can automatically register routes by placing them in the views/ folder with each module exporting a path constant, and also optionally method and fragment — to indicate routes that deliver standalone HTML fragments.

  • We can import JavaScript files with the .client.js suffix and CSS modules from server route modules and still be sure they will be loaded on the client after rendering.

All the setup requires is Fastify and Vite, resulting in an absolute minimal, low-level setup with minimum dependencies. For comparison, below are the node_modules sizes of this example application and a starter Next.js app built with create-next-app:

micro-htmx-framework-tutorial102mb
next-app-starter200mb

All of these features have been packed as @fastify/htmx, which is now the official JSX and HTMX renderer for @fastify/vite and is available on npm.

We'll be using it to rewrite the Movie Quotes App.

Rewriting the Movie Quotes App

For this part we're using a fork of the Movie Quotes App tutorial source code. This app actually contains two services, one for the API, and another for serving the frontend.

└── apps/
    ├── movie-quotes-api
    └── movie-quotes-frontend

The API is served from movie-quotes-api, a Fastify application running via the Platformatic DB CLI. The frontend is an Astro SPA which makes requests to the API retrieving data only. The frontend is served in this example via Astro's own development server, but its bundle could be served from a variety of servers.

It's a simple app with four routes:

  • the index page, pages/index.astro.

  • the add movie quote page, pages/add.astro.

  • the edit quote page, pages/edit/[id.astro].

  • and the delete quote page, pages/delete/[id.astro].

All movie quote associated actions have their own components as well. And then there are some vanilla JavaScript libraries responsible for a few bits of functionality, such as talking to the Platformatic DB GraphQL API and confirming actions before running them.

Below is the full source code structure for the original movie-quotes-frontend service.

.
└── movie-quotes-frontend-htmx/
    ├── components/
    │   ├── QuoteActionDelete.astro
    │   ├── QuoteActionEdit.astro
    │   ├── QuoteActionLike.astro
    │   └── QuoteForm.astro
    ├── layouts/
    │   └── Layout.astro
    ├── lib/
    │   ├── quotes-api.js
    │   └── request-utils.js
    ├── pages/
    │   ├── delete/
    │   │   └── [id].astro
    │   ├── edit/
    │   │   └── [id].astro
    │   ├── add.astro
    │   └── index.astro
    └── scripts/
        └── quote-actions.js

Rewriting the boilerplate

We'll start by creating a exact copy of movie-quotes-frontend for the HTMX version, making the directory structure now look like the following:

└── apps/
    ├── movie-quotes-api
    ├── movie-quotes-frontend
    └── movie-quotes-frontend-htmx

Next, in movie-quotes-frontend-htmx we'll add server.js to replace the Astro server:

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'
import FastifyFormBody from '@fastify/formbody'

const server = Fastify({
  logger: {
    transport: {
      target: '@fastify/one-line-logger'
    }
  }
})

await server.register(FastifyFormBody)
await server.register(FastifyVite, { 
  root: import.meta.url, 
  renderer: '@fastify/htmx',
})

await server.vite.ready()
await server.listen({ port: 3000 })

And a vite.config.js file to import @fastify/htmx's accompanying Vite plugin and set up CSS modules to use the camel case convention for class names:

import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

import inject from '@rollup/plugin-inject'
import viteFastifyHtmx from '@fastify/htmx/plugin'

export default {
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
  plugins: [
    viteFastifyHtmx()
  ],
  css: {
    modules: {
      localsConvention: 'camelCase'
    }
  }
}

@fastify/htmx is a brand new renderer for @fastify/vite built reusing most of the code in this tutorial. You can read the release notes here.

Loading .env files

Astro handles .env files automatically, and so does Vite. In fact, Astro's support for .env files comes from Vite's own built-in support. But we also need to make .env files available to Fastify. In this example we'll use fluent-env, a convenient wrapper around env-schema and fluent-json-schema.

All we need to do is add the following import at the top of server.js:

import `fluent-env/auto`

And change the PUBLIC_GRAPHQL_API_ENDPOINT variable to VITE_GRAPHQL_API_ENDPOINT to match Vite's default standard. Only environment variables prefixed with VITE_ are allowed to make it into the bundle.

In the end, this is not going to be required at all as since we're not ever running code that relies on the single environment variable of this application on the client, but it's still good to know.

Generating a Platformatic DB client

Platformatic CLI has had for some time the ability to automatically generate a functioning GraphQL or REST API client for your Platformatic DB service.

Next we'll replace lib/quotes-api.js with an automatically generated GraphQL client via Platformatic CLI. First cd to apps/movie-quotes-api and start the service:

npm run start

Then in our movie-quotes-frontend-htmx rewrite, first cd to src/lib and run:

npm i @platformatic/client --save
npx platformatic client http://127.0.0.1:3042/graphql --name quotes

You should see an output like the following:

[12:35:29] INFO: Client generated successfully into src/lib/quotes

Even though the generated client code is written in CommonJS, we can still import it from our ESM server.js file because it's explicitly tagged as CommonJS via the .cjs extension. Here's our updated server.js registering the plugin:

import 'fluent-env/auto'

import FastifyFormBody from '@fastify/formbody'
import FastifyVite from '@fastify/vite'
import Fastify from 'fastify'

import quotesGraphQLClient from './src/lib/quotes/quotes.cjs'

const server = Fastify({
  logger: {
    transport: {
      target: '@fastify/one-line-logger',
    },
  },
})

await server.register(FastifyFormBody)

await server.register(quotesGraphQLClient, {
  url: process.env.VITE_GRAPHQL_API_ENDPOINT,
})

await server.register(FastifyVite, {
  root: import.meta.url,
  renderer: '@fastify/htmx',
})

await server.vite.ready()
await server.listen({ port: 3000 })

Rewriting the layout component

The main difference is that the main HTML shell needs to live in an index.html file, which just the body rendered by the main layout component. Here's how the original layouts/Layout.astro component reads:

---
export interface Props {
  title: string;
  page?: string;
}

const { title, page } = Astro.props;

const navActiveClasses = "font-bold bg-yellow-400 no-underline";
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body class="py-8">
    <header class="prose mx-auto mb-6">
      <h1>🎬 Movie Quotes</h1>
    </header>
    <nav class="prose mx-auto mb-6 border-y border-gray-200 flex">
      <a href="/?sort=createdAt" class={`p-3 ${page === "listing-createdAt" && navActiveClasses}`}>Latest quotes</a>
      <a href="/?sort=likes" class={`p-3 ${page === "listing-likes" && navActiveClasses}`}>Top quotes</a>
      <a href="/add" class={`p-3 ${page === "add" && navActiveClasses}`}>Add a quote</a>
    </nav>
    <section class="prose mx-auto">
      <slot />
    </section>
  </body>
</html>

And here's how layouts/defaults.jsx reads:

const navActiveClasses = 'font-bold bg-yellow-400 no-underline'

export default ({ req, children }) => {
  return (
    <>
      <header class="prose mx-auto mb-6">
        <h1>🎬 Movie Quotes</h1>
      </header>
      <nav class="prose mx-auto mb-6 border-y border-gray-200 flex">
        <a
          href="/?sort=createdAt"
          class={`p-3 ${req.page === 'listing-createdAt' && navActiveClasses}`}
        >
          Latest quotes
        </a>
        <a
          href="/?sort=likes"
          class={`p-3 ${req.page === 'listing-likes' && navActiveClasses}`}
        >
          Top quotes
        </a>
        <a href="/add" class={`p-3 ${req.page === 'add' && navActiveClasses}`}>
          Add a quote
        </a>
      </nav>
      <section class="prose mx-auto">{children}</section>
    </>
  )
}

Accompanied by this new index.html file:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link href="/index.css" rel="stylesheet">
<!-- head -->
</head>
<body class="py-8">
<!-- element -->
</body>
<!-- hydration -->
<script type="module" src="/:client.js"></script>
</html>

You'll see going from Astro to @kitajs/html-flavored JSX is rather straightforward.

Rewriting the like action component

In the original Astro version, the heart SVG that serves to perform a like action on a movie quote is controlled by client-side code, triggering client-side requests to the GraphQL API. That means importing @urql/core on the client bundle, a 20kb+ library.

export async function likeQuote (likeQuote) {
  likeQuote.classList.add('liked')
  likeQuote.classList.remove('cursor-pointer')

  const id = Number(likeQuote.dataset.quoteId)

  const { data } = await quotesApi.mutation(gql`
    mutation($id: ID!) {
      likeQuote(id: $id)
    }
  `, { id })

  if (data?.likeQuote) {
    likeQuote.querySelector('.likes-count').innerText = data.likeQuote
  }
}

We'll avoid that by going with HTMX. First we'll add an endpoint to return the number of likes for a movie quote, in plain-text:

server.post('/api/like-movie-quote/:id', async (req, reply) => {
  const id = Number(req.params.id)
  const liked = await req.quotes.graphql({
    query: `
      mutation($id: ID!) {
        likeQuote(id: $id)
      }
    `,
    variables: { id },
  })
  reply.type('text/plain')
  reply.send(liked.toString())
})

Note that we're already using Platformatic's autogenerated GraphQL client in this snippet. Very simple POST endpoint, runs a mutation and returns the updated value. Now we can use it in our new QuoteActionLike.tsx component to do the update:

import Html from '@kitajs/html'
import styles from './QuoteActionLike.module.css'
import './QuoteActionLike.client.js'

export interface Props extends Html.PropsWithChildren {
  id: number
  likes: number
}

export default ({ id, likes }: Props) => {
  return (
    <>
      <span
        data-like-quote
        hx-post={`/api/like-movie-quote/${id}`} 
        hx-target="find span[data-like-count]"
        class={`${styles.likeQuote} cursor-pointer mr-5 flex items-center`}
      >
        <svg
          role="img"
          aria-label="Like"
          class={`${styles.likeIcon} w-6 h-6 mr-2 text-red-600`}
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
          />
        </svg>
        <span 
          data-like-count
          class={`${styles.likesCount} w-8`}>
          {likes}
        </span>
      </span>
    </>
  )
}

Like Vue, Astro allows you to inline <style> tags in a single file component, which makes for an ergonomic development experience. In this example, we use CSS modules as a compromise, which are still elegant and clean nonetheless and supported by @fastify/htmx. Note how we also import ./QuoteActionLike.client.js, which is responsible for changing the classes in the heart SVG element:

import styles from './QuoteActionLike.module.css'

document.body.addEventListener(
  'htmx:afterRequest',
  ({ detail }) => {
    if (detail.elt.hasAttribute('data-like-quote')) {
      detail.elt.classList.add(styles.liked)
      detail.elt.classList.remove('cursor-pointer')
    }
  }
)

Rewriting the movie quotes listing

Remember the ability to register HTML fragments routes we added before? @fastify/htmx packs the same functionality, allowing us to have a QuoteListing.tsx component and a fragment route at the same time:

import QuoteActionDelete from '/components/QuoteActionDelete.tsx'
import QuoteActionEdit from '/components/QuoteActionEdit.tsx'
import QuoteActionLike from '/components/QuoteActionLike.tsx'

export const path = '/html/quotes'
export const fragment = true

export default async ({ req }) => {
  const allowedSortFields = ['createdAt', 'likes']
  const searchParamSort = req.query.sort
  const sort = allowedSortFields.includes(searchParamSort)
    ? searchParamSort
    : 'createdAt'

  const quotes = await req.quotes.graphql({
    query: `
      query {
        quotes(orderBy: {field: ${sort}, direction: DESC}) {
          id
          quote
          saidBy
          likes
          createdAt
          movie {
            id
            name
          }
        }
      }
    `,
  })

  req.page = `listing-${sort}`

  return (
    <main>
      {quotes.length > 0 ? (
        quotes.map((quote) => (
          <div class="border-b mb-6 quote">
            <blockquote class="text-2xl mb-0">
              <p class="mb-4">{quote.quote}</p>
            </blockquote>
            <p class="text-xl mt-0 mb-8 text-gray-400">
              — {quote.saidBy}, {quote.movie?.name}
            </p>
            <div class="flex flex-col mb-6 text-gray-400">
              <span class="flex items-center">
                <QuoteActionLike id={quote.id} likes={quote.likes} />
                <QuoteActionEdit id={quote.id} />
                <QuoteActionDelete id={quote.id} />
              </span>
              <span class="mt-4 text-gray-400 italic">
                Added {new Date(quote.createdAt).toUTCString()}
              </span>
            </div>
          </div>
        ))
      ) : (
        <p>No movie quotes have been added.</p>
      )}
    </main>
  )
}

And used by views/index.jsx as follows:

import QuoteListing from '/fragments/QuoteListing.tsx'

export const path = '/'

export const head = (
  <>
    <title>All quotes</title>
  </>
)

export default ({ req }) => {
  return <QuoteListing req={req} />
}

The interesting bit here is that even though we're just using it as JSX component, this very same component is used to register a /html/quotes which could be reused to reload it in place. I leave that as a fun exercise to the reader!

Wrapping up

Make sure to see full source code here.

In this blog, we delved into how you can use Fastify, Vite and @fastify/vite to produce a small, low-overhead application setup that has the absolute minimal set of dependencies and moving parts. Ideally, this will have demystified a few layers of complexity implementing common features of full stack frameworks.

In terms of raw SSR performance, both implementations behave similarly, with autocannon giving an average of 15k requests per second. However the Fastify implementation contains a fraction of the dependencies from the Astro framework, making the overall development setup much smaller.