Jeff Huleatt

/ posts

Astro is the meta-framework for htmx

Published

Memo from the CEO

htmx needs a meta-framework.

For developers that use JavaScript on the backend, meta-frameworks like Next.js, Nuxt, SvelteKit, and SolidStart add useful tooling and conventions to make it easier to build full-stack web apps.

The htmx server examples page has helpful Node.js samples, but they’re either express or hapi servers (plus Mustache or Pug for templating) that are missing a lot modern conveniences. I’m looking for things like automatic TypeScript support, hot reloading, and bundling. Also, I want to write in a JSX-like language!

As the CEO of htmx, I propose that Astro should be the metaframework of choice for htmx. And, to prove it, I’ve built this post with Astro and htmx.

Does this post use HTMX?

Astro’s pit of success

Astro aims to make it difficult to build a slow site. So, before we get to why Astro is a great fit for htmx, let’s fall down the “pit of success” and review features of Astro that help us avoid even htmx’s tiny JS payload.

Default

Astro’s default is to generate static HTML and CSS at build time (SSG) with no JavaScript at all. It is a great starting place.

---
// This runs only at build time
const greeting = 'Hello world!';
const now = Date.now();
---
<h1>{greeting}</h1>
<time>{now}</time>

Server-rendering

For SSR (On-demand rendering in Astro parlance), set the special prerender export to false.

---
export const prerender = false;

// This now runs on every request
const greeting = 'Hello world!';
const now = Date.now();
---
<h1>{greeting}</h1>
<time>{now}</time>

When we’re running the Astro dev server, any changes to our SSR code will automatically reload, so we don’t need to manually re-start the server every time we change something.

Forms

Astro has helpers to process things like formData, and also ships with Zod to easily validate formData.

---
import { z as Zod } from 'astro:content';
export const prerender = false;

let word, uppercasedWord;
if (Astro.request.method === "POST") {
  try {
    const data = await Astro.request.formData();
    word = Zod.string().parse(data.get("word"));
    uppercasedWord = word.toUpperCase();
  } catch (error) {
    if (error instanceof Error) {
      Astro.response.status = 400;
      Astro.response.statusText = error.message;
    }
  }
}
---
<h1>Uppercaser</h1>
{uppercasedWord && <h2>{uppercasedWord}</h2>}
<form method="POST">
  <label>
    Word:
       <input type="text" name="word" value={word || ''} required />
  </label>
  <button>Submit</button>
</form>

This is how Astro handles standard, full-page form submissions. We can use this same logic in our htmx endpoints to handle form data without a page reload.

Adding some JavaScript

Using the <script> tag in a .astro file lets you write a little bit of JavaScript where you need it, with built-in support for TypeScript, npm imports, and bundling.

---
---
<script>console.log("Woohoo! I'm running in the browser.");</script>

But I need Suspense

React’s Suspense is cool for deferring server-rendering of slow-loading components while displaying a fallback.

Astro’s Server islands do the same thing! The only cost is a tiny bit of JavaScript to handle loading.

---
import MyComponent from '~/components/MyComponent'
---
<MyComponent server:defer>
  <p slot="fallback">Loading a server island...</p>
</MyComponent>

Loading a server island…

Astro is great

There are more cool things about Astro than can fit here, like opt-in prefetching, view transitions support, and content collections.

That’s enough Astro talk! We want to fully realize HATEOS. Let’s use htmx in an Astro project.

Using htmx in Astro

Adding a script tag

  1. Install the package
npm install htmx.org
  1. Add this script tag to a page that needs htmx:
---
// some frontmatter
---
<head>
<!-- other stuff -->
  <script>
    import htmx from "htmx.org";

    if (import.meta.env.DEV === true) {
      // https://htmx.org/docs/#debugging
      htmx.logAll();
    }

    // https://htmx.org/docs/#view-transitions
    htmx.config.globalViewTransitions = true

    // https://docs.astro.build/en/guides/view-transitions/#astroafter-swap
    document.addEventListener("astro:after-swap", () => {
      htmx.process(document.body);
    });
  </script>
</head>

Now, we’ve got htmx, we’re logging all events to console in dev mode, and View Transitions are enabled.

Serving htmx

There are two settings to change in .astro frontmatter to serve htmx. We need to force a server render, and tell Astro not to return a full HTML document:

---
/**
 * This is an htmx component, so we always want to
 * server-render, and we want to return partials
 * instead of full HTML pages
 *
 * https://docs.astro.build/en/basics/astro-pages/#page-partials
 * https://docs.astro.build/en/guides/on-demand-rendering/#enabling-on-demand-rendering
*/
export const partial = true;
export const prerender = false;
---

<p>Hello world, I'm an htmx element</p>

File-based routing

Astro’s file-based routing makes building handlers for htmx api endpoints so much easier than an express-based server. Instead of setting up individual route handlers and importing the right file for the right route, we organize our htmx endpoints in folders. src/pages/api/increment.astro becomes an api with the url /api/increment.

This convention makes it quick to add new htmx endpoints.

There is also support for dynamic routes, like src/pages/api/increment/[count].astro, which would let me call the url /api/incremement/1, api/increment/2, etc.

Limitations

Astro’s partials don’t support script tags or style tags. Therefore, those need to be global on the page instead of being specified in the .astro file for the htmx component.

React and htmx?

What if we decide we need some features of a client-side framework for specific parts of a page? Since Astro has official integrations for popular front-end frameworks, we can mix and match htmx and React easily.

---
import MyReactComponent from '../components/MyReactComponent';
---

<MyReactComponent />

I'm a React component! Click the button!

Wait, the button doesn’t work?! By default, Astro doesn’t actually send any React code to the client! This is the “pit of success” in action again. Astro wants us to make the conscious decision to ship extra code to the browser.

To do that, we need to add the client:load directive:

---
import MyReactComponent from '../components/MyReactComponent';
---

<MyReactComponent client:load/>

Now, we have an interactive React component!

I'm a React component! Click the button!

Though htmx devs and React devs are natural enemies, I actually think this can be a powerful pattern for stuff that’s better done on the client. And, since Astro supports pretty much any framework, we can do this with Svelte, or Solid, or whatever!

I'm a React component! Click the button!

I'm an htmx component! Click the button!

I'm a Svelte component! Click the button!

I'm a Solid component! Click the button!

That’s enough framework talk, though. By default, Astro is simple and doesn’t force us to use any of this. We can explicitly add these framework integrations, or just ignore them.

Give it a try

Astro adds a lot of nice developer experience improvements to htmx while staying true to the “just write HTML” ethos. It gives us file-based routing, hot reloading, TypeScript, and so much more from a meta-framework that does its best to help us build the fastest site possible.

And, if you do decide that you really, really need to sprinkle in some React components, Astro has convenient ways to do that without breaking the (bundle size) bank.


jokey 90s-style banner that says 'site created with htmx the right way'