Table of contents
- Starting a new Vite project
- Turning it into a Fastify application
- Configuring the client
- Configuring the server
- Configuring build scripts
- Ensuring route client imports
- Leveraging Vite's SSR manifest
- Adding JSX support via @kitajs/html
- Adding support for HTML fragments
- Reviewing our setup
- Rewriting the Movie Quotes App
- Rewriting the boilerplate
- Loading .env files
- Generating a Platformatic DB client
- Rewriting the layout component
- Rewriting the like action component
- Rewriting the movie quotes listing
- Wrapping up
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 owndev
(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 theroutes
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 thepublic/
directory, but would need a separate@fastify/static
plugin instance which is not provided out of the box. For simplicity, we'll just movevite.svg
out of thepublic/
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/htm
l
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 apath
constant, and also optionallymethod
andfragment
— 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-tutorial | 102mb |
next-app-starter | 200mb |
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.