Skip to content

A little example

Let me do a very simple example involving Astro and htmx. This example does not use Alpine, but that is always an option when we want more client-side interactivity.

I want to sell you Astro and htmx first.

We’re going to have a page with 2 buttons, one to increment a counter, another to decrement the count.

See this thing in action at https://aha-test-flavio.fly.dev on a single fly.io server running in Virginia (info for network latency metrics).

I think this will demonstrate how easy this stack can be.

Install Astro

Terminal window
npm create astro@latest

Run the site and open it in VS Code

Terminal window
cd <project>
code .
npm run dev

Now create src/pages/index.astro

Write some server-side code to initialize a super simple data storage in a file called /tmp/count.txt, if the file does not exist, and we read the content of that file into a count variable that we add to the HTML (credits to theprimeagen for this clever idea):

---
import fs from 'node:fs'
try {
fs.accessSync('/tmp/count.txt')
} catch {
fs.writeFileSync('/tmp/count.txt', '0')
}
const count = fs.readFileSync('/tmp/count.txt', 'utf-8')
---
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' type='image/svg+xml' href='/favicon.svg' />
<meta name='viewport' content='width=device-width' />
<meta name='generator' content='{Astro.generator}' />
<title>Astro</title>
</head>
<body>
<h1>Count: {count}</h1>
</body>
</html>

Result in the browser so far:

In Astro the part between --- at the top is ran server-side, and the part below is the HTML returned to the client.

You could hook a database or anything, but that’s just a simple thing we can do to get started without using any 3rd party library.

Let’s now install htmx.

Just add this <script> tag to the HTML returned by index.astro:

<script src="https://unpkg.com/htmx.org@1"></script>

htmx is installed.

Now we can create the buttons to increment or decrement the count:

<body>
<h1>Count: {count}</h1>
<button hx-post="/api/increment">Increment</button>
<button hx-post="/api/decrement">Decrement</button>
</body>

When you click the Increment button, htmx will issue a POST request to /api/increment.

Create src/pages/api/increment.astro

---
import fs from 'node:fs'
export const partial = true
const count = +fs.readFileSync('/tmp/count.txt', 'utf-8').trim() + 1
fs.writeFileSync('/tmp/count.txt', count.toString())
---
{count}

export const partial = true tells Astro this returns a simple “HTML fragment”, not a full page.

Clicking a button will now return the new count inside the button, because htmx by default swaps the returned HTML into the innerHTML of the element that triggered the network request.

You can change the HTML to

<body>
<h1>
Count: <span id='count'>{count}</span>
</h1>
<button hx-post='/api/increment' hx-target='#count'>
Increment
</button>
<button hx-post='/api/decrement' hx-target='#count'>
Decrement
</button>
</body>

and now the count value is updated dynamically.

Click the button, you’ll see the count increment correctly:

Notice we shipped HTML (in this case, we just returned a number, but it’s returned as text/html mime type, not in a different format like JSON for example) back to the client, and this HTML is swapped into the page in the place we want.

We also create the “API call” to decrement the count in src/pages/api/decrement.astro

---
import fs from 'node:fs'
export const partial = true
const count = +fs.readFileSync('/tmp/count.txt', 'utf-8').trim() - 1
fs.writeFileSync('/tmp/count.txt', count.toString())
---
{count}

For simplicity we’re duplicating the file access logic, but bear with me.

All the count updates are happening without a full page reload, without having to write any JavaScript ourselves, without a “SPA” framework.

In the network panel of your browser DevTools you can see all the requests that just return some bits of HTML.

Reloading the page shows you the current count. The state is all managed on the server.

Let me tell you about oob swaps in htmx, because this will blow your mind.

In the HTML returned from /api/decrement or /api/increment, instead of returning {count} you could return:

<span id='count' hx-swap-oob='true'>
{count}
</span>

and you wouldn’t need to have hx-target='#count' on the buttons any more. The HTML generated on the server decides what to swap (note that in this case you need to add hx-swap='none' on the button to prevent the inner HTML of the button to be replaced with empty content).

The amazing thing is you can have multiple elements in your returned HTML with hx-swap-oob='true' replacing different parts of your application.

This was just a little example of using Astro to generate the HTML and htmx to drive client-to-server interactivity in a way you’d usually think you’d need a complex SPA framework, and a ton of JavaScript, but here we didn’t write a single line of client-side JavaScript (we did write JS on the backend to read/write the state to file, but this is another story).

I’ve been using this stack to build a much more complex app, with lots of screens and interacion and login and database, and the approach scales pretty well.

Can this work for your use case too? As they say, it depends. Try it for some small scale stuff and see for yourself.

Full app code:

src/pages/index.astro

---
import fs from 'node:fs'
try {
fs.accessSync('/tmp/count.txt')
} catch {
fs.writeFileSync('/tmp/count.txt', '0')
}
const count = fs.readFileSync('/tmp/count.txt', 'utf-8')
---
<html lang='en'>
<head>
<meta charset='utf-8' />
<link rel='icon' type='image/svg+xml' href='/favicon.svg' />
<meta name='viewport' content='width=device-width' />
<meta name='generator' content='{Astro.generator}' />
<title>Astro</title>
<script src='https://unpkg.com/htmx.org@1'></script>
</head>
<body>
<h1>Count: <span id='count'>{count}</span></h1>
<button hx-post='/api/increment' hx-swap='none'>Increment</button>
<button hx-post='/api/decrement' hx-swap='none'>Decrement</button>
</body>
</html>

src/pages/api/increment.astro

---
import fs from 'node:fs'
export const partial = true
const count = +fs.readFileSync('/tmp/count.txt', 'utf-8').trim() + 1
fs.writeFileSync('/tmp/count.txt', count.toString())
---
<span id='count' hx-swap-oob='true'>
{count}
</span>

src/pages/decrement.astro

---
import fs from 'node:fs'
export const partial = true
const count = +fs.readFileSync('/tmp/count.txt', 'utf-8').trim() - 1
fs.writeFileSync('/tmp/count.txt', count.toString())
---
<span id='count' hx-swap-oob='true'>
{count}
</span>