Full-stack web development with HTMX and Bun, Part 2: Pug templating

By Matthew Tyson

In the first half of this article, we set up a web development stack and created a simple example application using Bun, HTMX, Elysia, and MongoDB. Here, we'll continue exploring our new stack while cleaning up and abstracting the example application's data access layer and adding more complex HTMX interactions. We'll also add another component to the tech stack: Pug, a popular JavaScript template engine that works well with HTMX and helps with configuring DOM interactions.

The example application

Our example application currently consists of a form and a table. The form lets users enter quotes along with their authors, which can then be searched and displayed using the application's user interface. I've added a bit of CSS to the interface to make it look more modern than what we left off with in Part 1:

Here’s the front-end code for the updated interface:

<script src="https://unpkg.com/htmx.org@1.9.10"></script> <form hx-post="/add-quote" hx-swap-oop="beforeend:#data-list" hx-trigger="every time"> <input type="text" name="quote" placeholder="Enter quote"> <input type="text" name="author" placeholder="Enter author"> <button type="submit">Add Quote</button></form> <ul id="data-list"></ul> <button hx-get="/quotes" hx-target="#data-list">Load Data</button>

We’re using HTMX to drive the process of submitting the form and loading data into the table. I've also cleaned up the application's back end so the database connectivity is now shared. Here's that portion of src/index.ts:

import { Elysia } from "elysia";import { staticPlugin } from '@elysiajs/static';const { MongoClient } = require('mongodb');// Database connection detailsconst url = "mongodb://127.0.0.1:27017/quote?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+1.8.0";const dbName = "quote";const collectionName = "quotes";let client = new MongoClient(url, { useUnifiedTopology: true });// Connect to the database (called only once)async function connectToDatabase() { try { await client.connect(); } catch (error) { console.error(error); throw error; // Re-throw the error to indicate connection failure } return { client, collection: client.db(dbName).collection(collectionName) };}// Close the database connectionasync function closeDatabaseConnection(client) { await client.close();}

What we're doing here is defining the database URL as the default MongoDB localhost address, along with a database and a collection name. Then, we use an async function, connectToDatabase(), to connect the client and return it connected to the collection. Our code can then call this method whenever it needs to access the database, and when it's done it can call client.close().

Using the database connection

Let’s look at how our server endpoints will use this database support. For brevity, I’m just showing the /quotes endpoint that drives the table:

// Close the database connectionasync function closeDatabaseConnection(client) { await client.close();}async function getAllQuotes(collection) { try { const quotes = await collection.find().toArray(); // Build the HTML table structure let html = '<table border="1">'; html += '<tr><th>Quote</th><th>Author</th></tr>'; for (const quote of quotes) { html += `<tr><td>${quote.quote}</td><td>${quote.author}</td></tr>`; } html += '</table>'; return html; } catch (error) { console.error("Error fetching quotes", error); throw error; // Re-throw the error for proper handling }}// Main application logicconst app = new Elysia() .get("/", () => "Hello Elysia") .get("/quotes", async () => { try { const { client, collection } = await connectToDatabase(); const quotes = await getAllQuotes(collection); await closeDatabaseConnection(client); return quotes; } catch (error) { console.error(error); return "Error fetching quotes"; } }) .use(staticPlugin()) .listen(3000);console.log( ` Elysia is running at ${app.server?.hostname}:${app.server?.port}`);

This gives us a back-end /quotes GET endpoint that we can call to get the quotes data. The endpoint calls the getAllQuotes() method, which uses the collection from connectToDatabase() to get the array of quotes and authors. It then generates the HTMX for the rows.

Finally, we send a response holding the rows as HTMX, and the rows are inserted into the table.

Add the Pug templating engine

Manually creating the row HTMX can cause frustration and errors. A templating engine lets us define the HTMX structure in an eternal file with a clean syntax.

The most popular HTML templating engine for JavaScript is Pug. Using it will make creating the views on the server much easier and more scalable than inlining in the JavaScript code. The basic idea is to take our data objects and pass them into the template, which applies the data and outputs HTML. The difference here is that we are generating HTMX rather than HTML. We can do this because HTMX is essentially HTML with extensions.

To start, add the Pug library to the project with: $ bun add pug.

When that completes, create a new directory at the root of the project called /views: ($ mkdir views), then add a new file called quotes.pug:

doctype htmlh1 Quotestable thead tr th Quote th Author th Actions tbody each quote in quotes tr(id=`quote-${quote._id}`) td #{quote.quote} td #{quote.author} td button(hx-delete=`/quotes/${quote._id}` hx-trigger="click" hx-swap="closest tr" hx-confirm="Are you sure?") Delete #{quote._id}

Pug uses indentation to handle nested elements. Attributes are held within parentheses. Plain text such as the word Delete is provided as-is. All of this gives us a compact way to describe HTML and/or HTMX. See the Pug homepage to learn more about its syntax.

Notice that inside a string, we need to use ${}. The #{} syntax lets you reference any data objects that were injected into the template. This is similar to token interpolation in a framework like React. The basic idea is to define the overall HTML/HTMX structure, then provide variables to the template that are referenced with #{} and ${}.

We provide the variables back on the server /quotes endpoint, which uses getAllQuotes():

import pug from 'pug';//...async function getAllQuotes(collection) { try { const quotes = await collection.find().toArray(); // Render the Pug template with the fetched quotes const html = pug.compileFile('views/quotes.pug')({ quotes }); return html; } catch (error) { console.error("Error fetching quotes", error); throw error; // Re-throw the error for proper handling }}

So, we get the quotes from the database, then compile the Pug template and pass the quotes in. Then Pug does the job of folding together the HTML and the data. The overall flow is:

The resulting screen looks something like this:

DOM interactions: Deleting a row

Now we need to get our Delete button working. Simply issuing a delete request and handling it on the server and database is easy to do with what we’ve seen already, but what about updating the table to reflect the change?

There are several ways to approach the update. We could simply refresh the entire table, or we could use JavaScript or HTMX to delete the row from the table. Ideally, we’d like to use the latter option and keep everything as HTMX.

In our views/quotes.pug template, we can use pure HTMX to delete the row:

tbody(hx-target="closest tr" hx-swap="outerHTML") each quote in quotes tr(id=`quote-${quote._id}`) td #{quote.quote} td #{quote.author} td button(hx-delete=`/quotes/${quote._id}` hx-trigger="click" hx-confirm="Are you sure?") Delete

The essential parts here are the hx-target=”closest tr” and hx-swap=”outerHTML” on the tbody. (The hx-confirm allows you to provide a confirm dialog.) The hx-target says to replace the nearest tr to the trigger element (the button) with the response. The outHTML in hx-swap ensures we remove the whole table row element, not just its contents. On the server side, we return a successful (HTTP 200) with an empty body, so HTMX will simply delete the row:

async function deleteQuote(collection, quoteId) { try { const result = await collection.deleteOne({ _id: new ObjectId(quoteId) }); if (result.deletedCount === 1) { return ""; } else { throw new Error( "Quote not found"); } } catch (error) { console.error("Error deleting quote", error); throw error; // Re-throw the error for proper handling }}

Here, we are just starting to get into more involved DOM interactions. HTMX can also add simple transition effects to swaps in a row-deletion scenario like ours. You can see an example on the HTMX homepage.

Conclusion

Although this two-part tutorial incorporates newer technologies like Bun and Elysia, the most noticeable component is HTMX. It truly changes the way an application works as compared to conventional JSON APIs.

When combined with a templating engine like Pug and a database like MongoDB, the work of generating UIs and handling requests is smooth. As an application grows in size, Pug features like template inheritance also come in handy.

For DOM interactions, HTMX sports flexible functionality out of the box via hx-swap and hx-target. For more involved use cases, you can always fall back on JavaScript.

In general, this whole stack works well together. You might also appreciate the speed of Bun whenever you need to drop into the command line to do something like add a dependency.

You can find the code for this tutorial on my GitHub repository.

© Info World